Initial commit

This commit is contained in:
AJ
2025-10-03 23:43:21 -04:00
commit d579029492
288 changed files with 100048 additions and 0 deletions

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
#Ignore thumbnails created by Windows
Thumbs.db
#Ignore files built by Visual Studio
*.obj
*.exe
*.pdb
*.user
*.aps
*.pch
*.vspscc
*_i.c
*_p.c
*.ncb
*.suo
*.tlb
*.tlh
*.bak
*.cache
*.ilk
*.log
[Bb]in
[Dd]ebug*/
*.lib
*.sbr
obj/
[Rr]elease*/
_ReSharper*/
[Tt]est[Rr]esult*
.vs/
.idea/
#Nuget packages folder
packages/
/MoneyMap/wwwroot/lib/
/MoneyMap/wwwroot/receipts/

25
MoneyMap.sln Normal file
View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.14.36429.23
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MoneyMap", "MoneyMap\MoneyMap.csproj", "{B273A467-3592-4675-B1EC-C41C9CE455DB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B273A467-3592-4675-B1EC-C41C9CE455DB}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {9BC6A70A-C19A-442D-A77E-74662945CACE}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,107 @@
using System;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Data
{
public class MoneyMapContext : DbContext
{
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
public DbSet<Card> Cards => Set<Card>();
public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<Receipt> Receipts => Set<Receipt>();
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ---------- CARD ----------
modelBuilder.Entity<Card>(e =>
{
e.Property(x => x.Issuer).HasMaxLength(100).IsRequired();
e.Property(x => x.Last4).HasMaxLength(4).IsRequired();
e.Property(x => x.Owner).HasMaxLength(100);
e.HasIndex(x => new { x.Issuer, x.Last4, x.Owner });
});
// ---------- TRANSACTION ----------
modelBuilder.Entity<Transaction>(e =>
{
e.Property(x => x.TransactionType).HasMaxLength(20);
e.Property(x => x.Name).HasMaxLength(200).IsRequired();
e.Property(x => x.Memo).HasMaxLength(500).HasDefaultValue(string.Empty);
e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
e.Property(x => x.Category).HasMaxLength(100);
e.Property(x => x.CardLast4).HasMaxLength(4);
// Card (required). If a card is deleted, block delete when txns exist (no cascades).
e.HasOne(x => x.Card)
.WithMany(c => c.Transactions)
.HasForeignKey(x => x.CardId)
.OnDelete(DeleteBehavior.Restrict);
});
// ---------- RECEIPT ----------
modelBuilder.Entity<Receipt>(e =>
{
e.Property(x => x.FileName).HasMaxLength(260).IsRequired();
e.Property(x => x.ContentType).HasMaxLength(100).HasDefaultValue("application/octet-stream");
e.Property(x => x.StoragePath).HasMaxLength(1024).IsRequired();
e.Property(x => x.FileHashSha256).HasMaxLength(64).IsRequired();
e.Property(x => x.Merchant).HasMaxLength(200);
e.Property(x => x.Subtotal).HasColumnType("decimal(18,2)");
e.Property(x => x.Tax).HasColumnType("decimal(18,2)");
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
e.Property(x => x.Currency).HasMaxLength(8);
// Receipt must belong to a Transaction. If txn is deleted, cascade remove receipts.
e.HasOne(x => x.Transaction)
.WithMany(t => t.Receipts)
.HasForeignKey(x => x.TransactionId)
.OnDelete(DeleteBehavior.Cascade);
});
// ---------- RECEIPT PARSE LOG ----------
modelBuilder.Entity<ReceiptParseLog>(e =>
{
e.Property(x => x.Provider).HasMaxLength(50).IsRequired();
e.Property(x => x.Model).HasMaxLength(100).IsRequired();
e.Property(x => x.ProviderJobId).HasMaxLength(100);
e.Property(x => x.ExtractedTextPath).HasMaxLength(1024);
e.HasOne(x => x.Receipt)
.WithMany(r => r.ParseLogs)
.HasForeignKey(x => x.ReceiptId)
.OnDelete(DeleteBehavior.Cascade);
});
// ---------- RECEIPT LINE ITEM ----------
modelBuilder.Entity<ReceiptLineItem>(e =>
{
e.Property(x => x.Description).HasMaxLength(300).IsRequired();
e.Property(x => x.Unit).HasMaxLength(16);
e.Property(x => x.UnitPrice).HasColumnType("decimal(18,4)");
e.Property(x => x.LineTotal).HasColumnType("decimal(18,2)");
e.Property(x => x.Sku).HasMaxLength(64);
e.Property(x => x.Category).HasMaxLength(100);
e.HasOne(x => x.Receipt)
.WithMany(r => r.LineItems)
.HasForeignKey(x => x.ReceiptId)
.OnDelete(DeleteBehavior.Cascade);
});
// ---------- Extra SQL Serverfriendly indexes ----------
// Fast filtering by date/amount/category
modelBuilder.Entity<Transaction>().HasIndex(x => x.Date);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Amount);
modelBuilder.Entity<Transaction>().HasIndex(x => x.Category);
}
}
}

View File

@@ -0,0 +1,352 @@
// <auto-generated />
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("20251004000603_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
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.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique();
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CardId")
.HasColumnType("int");
b.Property<string>("CardLast4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("Amount");
b.HasIndex("CardId");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
.IsUnique();
b.ToTable("Transactions");
});
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.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Card");
});
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
}
}
}

View File

@@ -0,0 +1,210 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Cards",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Issuer = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Last4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: false),
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cards", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Transactions",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
TransactionType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Memo = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CardId = table.Column<int>(type: "int", nullable: false),
CardLast4 = table.Column<string>(type: "nvarchar(4)", maxLength: 4, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Transactions", x => x.Id);
table.ForeignKey(
name: "FK_Transactions_Cards_CardId",
column: x => x.CardId,
principalTable: "Cards",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Receipts",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
TransactionId = table.Column<long>(type: "bigint", nullable: false),
FileName = table.Column<string>(type: "nvarchar(260)", maxLength: 260, nullable: false),
ContentType = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false, defaultValue: "application/octet-stream"),
StoragePath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
FileSizeBytes = table.Column<long>(type: "bigint", nullable: false),
FileHashSha256 = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
UploadedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
Merchant = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ReceiptDate = table.Column<DateTime>(type: "datetime2", nullable: true),
Subtotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Tax = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Currency = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Receipts", x => x.Id);
table.ForeignKey(
name: "FK_Receipts_Transactions_TransactionId",
column: x => x.TransactionId,
principalTable: "Transactions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ReceiptLineItems",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
LineNumber = table.Column<int>(type: "int", nullable: false),
Description = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Quantity = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
Unit = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
UnitPrice = table.Column<decimal>(type: "decimal(18,4)", nullable: true),
LineTotal = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
Sku = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Category = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ReceiptLineItems", x => x.Id);
table.ForeignKey(
name: "FK_ReceiptLineItems_Receipts_ReceiptId",
column: x => x.ReceiptId,
principalTable: "Receipts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ReceiptParseLogs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ReceiptId = table.Column<long>(type: "bigint", nullable: false),
Provider = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ProviderJobId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
StartedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: false),
CompletedAtUtc = table.Column<DateTime>(type: "datetime2", nullable: true),
Success = table.Column<bool>(type: "bit", nullable: false),
Confidence = table.Column<decimal>(type: "decimal(5,4)", nullable: true),
RawProviderPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
ExtractedTextPath = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
Error = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ReceiptParseLogs", x => x.Id);
table.ForeignKey(
name: "FK_ReceiptParseLogs_Receipts_ReceiptId",
column: x => x.ReceiptId,
principalTable: "Receipts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cards_Issuer_Last4_Owner",
table: "Cards",
columns: new[] { "Issuer", "Last4", "Owner" });
migrationBuilder.CreateIndex(
name: "IX_ReceiptLineItems_ReceiptId_LineNumber",
table: "ReceiptLineItems",
columns: new[] { "ReceiptId", "LineNumber" });
migrationBuilder.CreateIndex(
name: "IX_ReceiptParseLogs_ReceiptId_StartedAtUtc",
table: "ReceiptParseLogs",
columns: new[] { "ReceiptId", "StartedAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_Receipts_TransactionId_FileHashSha256",
table: "Receipts",
columns: new[] { "TransactionId", "FileHashSha256" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Transactions_Amount",
table: "Transactions",
column: "Amount");
migrationBuilder.CreateIndex(
name: "IX_Transactions_CardId",
table: "Transactions",
column: "CardId");
migrationBuilder.CreateIndex(
name: "IX_Transactions_Category",
table: "Transactions",
column: "Category");
migrationBuilder.CreateIndex(
name: "IX_Transactions_Date",
table: "Transactions",
column: "Date");
migrationBuilder.CreateIndex(
name: "IX_Transactions_Date_Amount_Name_Memo_CardId",
table: "Transactions",
columns: new[] { "Date", "Amount", "Name", "Memo", "CardId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReceiptLineItems");
migrationBuilder.DropTable(
name: "ReceiptParseLogs");
migrationBuilder.DropTable(
name: "Receipts");
migrationBuilder.DropTable(
name: "Transactions");
migrationBuilder.DropTable(
name: "Cards");
}
}
}

View File

@@ -0,0 +1,373 @@
// <auto-generated />
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("20251004023633_AddCategoryMappings")]
partial class AddCategoryMappings
{
/// <inheritdoc />
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.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique();
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CardId")
.HasColumnType("int");
b.Property<string>("CardLast4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("Amount");
b.HasIndex("CardId");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
.IsUnique();
b.ToTable("Transactions");
});
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("CategoryMappings");
});
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.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Card");
});
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
}
}
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class AddCategoryMappings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Confidence",
table: "ReceiptLineItems");
migrationBuilder.CreateTable(
name: "CategoryMappings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Category = table.Column<string>(type: "nvarchar(max)", nullable: false),
Pattern = table.Column<string>(type: "nvarchar(max)", nullable: false),
Priority = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryMappings", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CategoryMappings");
migrationBuilder.AddColumn<decimal>(
name: "Confidence",
table: "ReceiptLineItems",
type: "decimal(5,4)",
nullable: true);
}
}
}

View File

@@ -0,0 +1,377 @@
// <auto-generated />
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("20251004034919_AddNotesToTransactions")]
partial class AddNotesToTransactions
{
/// <inheritdoc />
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.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique();
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CardId")
.HasColumnType("int");
b.Property<string>("CardLast4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("Amount");
b.HasIndex("CardId");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
.IsUnique();
b.ToTable("Transactions");
});
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("CategoryMappings");
});
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.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Card");
});
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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class AddNotesToTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Transactions",
type: "nvarchar(max)",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Notes",
table: "Transactions");
}
}
}

View File

@@ -0,0 +1,374 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MoneyMap.Data;
#nullable disable
namespace MoneyMap.Migrations
{
[DbContext(typeof(MoneyMapContext))]
partial class MoneyMapContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique();
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CardId")
.HasColumnType("int");
b.Property<string>("CardLast4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.HasIndex("Amount");
b.HasIndex("CardId");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "CardId")
.IsUnique();
b.ToTable("Transactions");
});
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("CategoryMappings");
});
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.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Card");
});
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
}
}
}

21
MoneyMap/Models/Card.cs Normal file
View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.Transactions;
namespace MoneyMap.Models;
public class Card
{
[Key]
public int Id { get; set; }
[MaxLength(100)]
public string Issuer { get; set; } = string.Empty; // e.g., VISA, MC
[MaxLength(4)]
public string Last4 { get; set; } = string.Empty; // "1234"
[MaxLength(100)]
public string Owner { get; set; } = string.Empty; // optional
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
}

View File

@@ -0,0 +1,11 @@
namespace MoneyMap.Models.Import;
public class TransactionCsvRow
{
public DateTime Date { get; set; }
public string Transaction { get; set; } = "";
public string Name { get; set; } = "";
public string Memo { get; set; } = "";
public decimal Amount { get; set; }
public string Category { get; set; } = "";
}

View File

@@ -0,0 +1,32 @@
using CsvHelper.Configuration;
namespace MoneyMap.Models.Import;
public sealed class TransactionCsvRowMap : ClassMap<TransactionCsvRow>
{
public TransactionCsvRowMap(bool hasCategory)
{
Map(m => m.Date).Name("Date");
Map(m => m.Transaction).Name("Transaction");
Map(m => m.Name).Name("Name");
Map(m => m.Memo).Name("Memo");
Map(m => m.Amount).Name("Amount");
if (hasCategory)
{
Map(m => m.Category).Name("Category");
}
else
{
if (hasCategory)
{
Map(m => m.Category).Name("Category");
}
else
{
Map(m => m.Category).Constant(string.Empty).Optional();
}
}
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models;
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
public class Receipt
{
[Key]
public long Id { get; set; }
// Link to transaction
public long TransactionId { get; set; }
public Transaction Transaction { get; set; } = null!;
// File metadata
[MaxLength(260)]
public string FileName { get; set; } = string.Empty;
[MaxLength(100)]
public string ContentType { get; set; } = "application/octet-stream";
[MaxLength(1024)]
public string StoragePath { get; set; } = string.Empty; // \\barge.lan\receipts\...\ or blob key
public long FileSizeBytes { get; set; }
[MaxLength(64)]
public string FileHashSha256 { get; set; } = string.Empty; // for dedupe
public DateTime UploadedAtUtc { get; set; } = DateTime.UtcNow;
// Parsed header fields (optional, set by parser job)
[MaxLength(200)]
public string? Merchant { get; set; }
public DateTime? ReceiptDate { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal? Subtotal { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal? Tax { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal? Total { get; set; }
[MaxLength(8)]
public string? Currency { get; set; }
// One receipt -> many parse attempts + many line items
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models;
[Index(nameof(ReceiptId), nameof(LineNumber))]
public class ReceiptLineItem
{
[Key]
public long Id { get; set; }
public long ReceiptId { get; set; }
public Receipt Receipt { get; set; } = null!;
public int LineNumber { get; set; }
[MaxLength(300)]
public string Description { get; set; } = string.Empty;
// ReceiptLineItem
[Column(TypeName = "decimal(18,4)")]
public decimal? Quantity { get; set; } // was missing
[MaxLength(16)]
public string? Unit { get; set; } // ea, lb, gal, etc.
[Column(TypeName = "decimal(18,4)")]
public decimal? UnitPrice { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal? LineTotal { get; set; }
[MaxLength(64)]
public string? Sku { get; set; }
[MaxLength(100)]
public string? Category { get; set; } // optional per-line categorization
}

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models;
[Index(nameof(ReceiptId), nameof(StartedAtUtc))]
public class ReceiptParseLog
{
[Key]
public long Id { get; set; }
public long ReceiptId { get; set; }
public Receipt Receipt { get; set; } = null!;
// Provider metadata as strings for flexibility
[MaxLength(50)]
public string Provider { get; set; } = string.Empty; // e.g., "OpenAI", "Azure", "Google", "Tesseract"
[MaxLength(100)]
public string Model { get; set; } = string.Empty; // e.g., "gpt-4o-mini"
[MaxLength(100)]
public string? ProviderJobId { get; set; }
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAtUtc { get; set; }
public bool Success { get; set; }
// ReceiptParseLog
[Column(TypeName = "decimal(5,4)")]
public decimal? Confidence { get; set; } // 0.00000.9999 is plenty
// Store full provider JSON payload for re-parsing/debug (keep out of hot paths)
public string RawProviderPayloadJson { get; set; } = "{}";
// Optional extracted text path if you persist a .txt alongside the image/PDF
[MaxLength(1024)]
public string? ExtractedTextPath { get; set; }
public string? Error { get; set; }
}

View File

@@ -0,0 +1,46 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Transactions;
namespace MoneyMap.Models;
[Index(nameof(Date), nameof(Amount), nameof(Name), nameof(Memo), nameof(CardId), IsUnique = true)]
public class Transaction
{
[Key]
public long Id { get; set; }
[Required]
public DateTime Date { get; set; }
[MaxLength(20)]
public string TransactionType { get; set; } = string.Empty; // "DEBIT"/"CREDIT" if present
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string Memo { get; set; } = string.Empty;
[Column(TypeName = "decimal(18,2)")]
public decimal Amount { get; set; } // negative = debit, positive = credit
[MaxLength(100)]
public string Category { get; set; } = string.Empty;
public string Notes { get; set; } = string.Empty;
// Card link + convenience
[ForeignKey(nameof(Card))]
public int CardId { get; set; }
public Card? Card { get; set; }
[MaxLength(4)]
public string? CardLast4 { get; set; } // parsed from Memo if present
public ICollection<Receipt> Receipts { get; set; } = new List<Receipt>();
[NotMapped] public bool IsCredit => Amount > 0;
[NotMapped] public bool IsDebit => Amount < 0;
}

27
MoneyMap/MoneyMap.csproj Normal file
View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.8.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PdfPig" Version="0.1.11" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
<Folder Include="Utility\" />
<Folder Include="wwwroot\receipts\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,111 @@
@page
@model MoneyMap.Pages.CategoryMappingsModel
@{
ViewData["Title"] = "Category Mappings";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Category Mappings</h2>
<div>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row mb-3">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Total Categories</div>
<div class="fs-3 fw-bold">@Model.TotalCategories</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Total Patterns</div>
<div class="fs-3 fw-bold">@Model.TotalMappings</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body text-center">
@if (Model.TotalMappings == 0)
{
<form method="post" asp-page-handler="SeedDefaults">
<button type="submit" class="btn btn-primary">
Load Default Mappings
</button>
</form>
}
else
{
<div class="text-muted">Mappings Loaded</div>
<div class="text-success fs-5">✓ Ready</div>
}
</div>
</div>
</div>
</div>
@if (Model.CategoryGroups.Any())
{
<div class="card shadow-sm">
<div class="card-header">
<strong>Category Patterns</strong>
<small class="text-muted ms-2">Transactions are auto-categorized when merchant names match these patterns</small>
</div>
<div class="card-body">
<div class="row">
@foreach (var group in Model.CategoryGroups)
{
<div class="col-md-6 col-lg-4 mb-3">
<div class="border rounded p-3 h-100">
<h6 class="text-primary mb-2">
@group.Category
<span class="badge bg-secondary">@group.Count</span>
</h6>
<div class="small">
@foreach (var pattern in group.Patterns.Take(5))
{
<div class="text-muted">• @pattern</div>
}
@if (group.Patterns.Count > 5)
{
<div class="text-muted fst-italic">+ @(group.Patterns.Count - 5) more...</div>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
<h5>No category mappings found</h5>
<p>Click "Load Default Mappings" above to import the default category patterns, or add your own custom mappings.</p>
</div>
}
<div class="mt-4">
<h5>How Auto-Categorization Works</h5>
<ul class="small text-muted">
<li>When you upload transactions, the system automatically checks the merchant name against these patterns</li>
<li>If a match is found, the transaction is automatically categorized</li>
<li>Categories in the CSV file (if present) take precedence over auto-categorization</li>
<li>Transactions with no match will have an empty category and can be categorized manually</li>
<li><strong>Special rule:</strong> Gas stations with purchases under $20 are categorized as "Convenience Store"</li>
</ul>
</div>

View File

@@ -0,0 +1,59 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class CategoryMappingsModel : PageModel
{
private readonly ITransactionCategorizer _categorizer;
public CategoryMappingsModel(ITransactionCategorizer categorizer)
{
_categorizer = categorizer;
}
public List<CategoryGroup> CategoryGroups { get; set; } = new();
public int TotalMappings { get; set; }
public int TotalCategories { get; set; }
[TempData]
public string? SuccessMessage { get; set; }
public async Task OnGetAsync()
{
var mappings = await _categorizer.GetAllMappingsAsync();
CategoryGroups = mappings
.GroupBy(m => m.Category)
.Select(g => new CategoryGroup
{
Category = g.Key,
Patterns = g.Select(m => m.Pattern).ToList(),
Count = g.Count()
})
.OrderBy(g => g.Category)
.ToList();
TotalMappings = mappings.Count;
TotalCategories = CategoryGroups.Count;
}
public async Task<IActionResult> OnPostSeedDefaultsAsync()
{
await _categorizer.SeedDefaultMappingsAsync();
SuccessMessage = "Default category mappings loaded successfully!";
return RedirectToPage();
}
public class CategoryGroup
{
public required string Category { get; set; }
public required List<string> Patterns { get; set; }
public int Count { get; set; }
}
}
}

View File

@@ -0,0 +1,263 @@
@page "{id:long}"
@model MoneyMap.Pages.EditTransactionModel
@{
ViewData["Title"] = "Edit Transaction";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Edit Transaction</h2>
<a asp-page="/Transactions" class="btn btn-outline-secondary">Back to Transactions</a>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Transaction Details</strong>
</div>
<div class="card-body">
<form method="post">
<input type="hidden" asp-for="Transaction.Id" />
<!-- Read-only fields -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label fw-bold">Date</label>
<div class="form-control-plaintext">@Model.Transaction.Date.ToString("yyyy-MM-dd")</div>
</div>
<div class="col-md-6">
<label class="form-label fw-bold">Amount</label>
<div class="form-control-plaintext">
<span class="fs-5 @(Model.Transaction.Amount >= 0 ? "text-success" : "text-danger")">
@Model.Transaction.Amount.ToString("C")
</span>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Merchant Name</label>
<div class="form-control-plaintext">@Model.Transaction.Name</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Memo</label>
<div class="form-control-plaintext">@Model.Transaction.Memo</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Card</label>
<div class="form-control-plaintext">@Model.Transaction.CardLabel</div>
</div>
<hr class="my-4" />
<!-- Editable fields -->
<div class="mb-3">
<label class="form-label fw-bold">Category</label>
<select class="form-select mb-2" id="categorySelect" onchange="handleCategoryChange()">
<option value="">(uncategorized)</option>
@foreach (var cat in Model.AvailableCategories)
{
<option value="@cat" selected="@(Model.Transaction.Category == cat)">@cat</option>
}
<option value="__custom__" selected="@Model.UseCustomCategory">+ Enter custom category</option>
</select>
<div id="customCategoryInput" style="display: @(Model.UseCustomCategory ? "block" : "none")">
<input asp-for="Transaction.Category"
class="form-control"
placeholder="Enter custom category" />
</div>
<input type="hidden" asp-for="Transaction.Category" id="categoryHidden" />
<div class="form-text">Select a category or create a new one</div>
<span asp-validation-for="Transaction.Category" class="text-danger"></span>
</div>
<div class="mb-3">
<label asp-for="Transaction.Notes" class="form-label fw-bold">Notes</label>
<textarea asp-for="Transaction.Notes"
class="form-control"
rows="3"
placeholder="Add any additional details about this transaction..."></textarea>
<div class="form-text">Optional: Add context or details to help you remember what this transaction was for</div>
<span asp-validation-for="Transaction.Notes" class="text-danger"></span>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Save Changes</button>
<a asp-page="/Transactions" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Receipts (@Model.Receipts.Count)</strong>
</div>
<div class="card-body">
@if (Model.Receipts.Any())
{
@foreach (var item in Model.Receipts)
{
<div class="card mb-3">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1">
<div class="fw-bold text-truncate" style="max-width: 200px;" title="@item.Receipt.FileName">
@item.Receipt.FileName
</div>
<small class="text-muted">
@(item.Receipt.FileSizeBytes / 1024.0).ToString("F1") KB
· @item.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy")
</small>
@if (!string.IsNullOrWhiteSpace(item.Receipt.Merchant))
{
<div class="small text-success mt-1">
<strong>@item.Receipt.Merchant</strong>
@if (item.Receipt.Total.HasValue)
{
<span> - @item.Receipt.Total.Value.ToString("C")</span>
}
</div>
}
</div>
</div>
@if (item.LineItems.Any())
{
<div class="border rounded p-2 mb-2 bg-light">
<div class="small fw-bold mb-1">Line Items (@item.LineItems.Count)</div>
<div style="max-height: 150px; overflow-y: auto;">
@foreach (var lineItem in item.LineItems)
{
<div class="d-flex justify-content-between small">
<span class="text-truncate" style="max-width: 120px;" title="@lineItem.Description">
@if (lineItem.Quantity.HasValue)
{
<text>@lineItem.Quantity.Value.ToString("0.##")x </text>
}
@lineItem.Description
</span>
<span class="text-end">@(lineItem.LineTotal?.ToString("C") ?? "-")</span>
</div>
}
</div>
</div>
}
<div class="d-flex gap-1 flex-wrap">
<a asp-page="/ViewReceipt" asp-route-id="@item.Receipt.Id"
target="_blank"
class="btn btn-sm btn-outline-primary">
View
</a>
@if (!item.LineItems.Any())
{
<form method="post" asp-page-handler="ParseReceipt" asp-route-receiptId="@item.Receipt.Id" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-info">Parse</button>
</form>
}
<form method="post" asp-page-handler="DeleteReceipt" asp-route-receiptId="@item.Receipt.Id"
onsubmit="return confirm('Delete this receipt?')" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger">Delete</button>
</form>
</div>
</div>
</div>
}
}
else
{
<p class="text-muted mb-3">No receipts attached</p>
}
<hr />
<form method="post" asp-page-handler="UploadReceipt" enctype="multipart/form-data">
<input type="hidden" asp-for="Transaction.Id" />
<div class="mb-2">
<label for="ReceiptFile" class="form-label small fw-bold">Upload Receipt</label>
<input asp-for="ReceiptFile" type="file" class="form-control form-control-sm" accept="image/*,.pdf" />
</div>
<button type="submit" class="btn btn-sm btn-primary w-100">Upload</button>
<div class="form-text small">
Accepts: JPG, PNG, PDF, GIF, HEIC (max 10MB)
</div>
</form>
</div>
</div>
<div class="card shadow-sm mt-3">
<div class="card-header">
<strong>Quick Tips</strong>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>Use the category dropdown to see existing categories</li>
<li>Leave category blank to mark as uncategorized</li>
<li>You can create new categories by typing them in</li>
</ul>
</div>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
function handleCategoryChange() {
const select = document.getElementById('categorySelect');
const customInput = document.getElementById('customCategoryInput');
const hiddenInput = document.getElementById('categoryHidden');
const categoryInput = document.querySelector('input[name="Transaction.Category"]');
if (select.value === '__custom__') {
customInput.style.display = 'block';
categoryInput.value = '';
categoryInput.focus();
} else {
customInput.style.display = 'none';
categoryInput.value = select.value;
}
}
// Update hidden field when custom input changes
document.addEventListener('DOMContentLoaded', function() {
const categoryInput = document.querySelector('input[name="Transaction.Category"]');
const select = document.getElementById('categorySelect');
if (categoryInput) {
categoryInput.addEventListener('input', function() {
if (select.value === '__custom__') {
// Keep custom selected when typing
}
});
}
// Initialize on page load
handleCategoryChange();
});
</script>
}

View File

@@ -0,0 +1,214 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class EditTransactionModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
private readonly IReceiptParser _receiptParser;
public EditTransactionModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptParser receiptParser)
{
_db = db;
_receiptManager = receiptManager;
_receiptParser = receiptParser;
}
[BindProperty]
public TransactionEditModel Transaction { get; set; } = new();
[BindProperty]
public IFormFile? ReceiptFile { get; set; }
[BindProperty]
public bool UseCustomCategory { get; set; }
public List<string> AvailableCategories { get; set; } = new();
public List<ReceiptWithItems> Receipts { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(long id)
{
var transaction = await _db.Transactions
.Include(t => t.Card)
.Include(t => t.Receipts)
.ThenInclude(r => r.LineItems)
.FirstOrDefaultAsync(t => t.Id == id);
if (transaction == null)
return NotFound();
Transaction = new TransactionEditModel
{
Id = transaction.Id,
Date = transaction.Date,
Name = transaction.Name,
Memo = transaction.Memo,
Amount = transaction.Amount,
Category = transaction.Category ?? "",
Notes = transaction.Notes ?? "",
CardLabel = transaction.Card != null
? $"{transaction.Card.Owner} - {transaction.Card.Last4}"
: $"•••• {transaction.CardLast4}"
};
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
{
Receipt = r,
LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List<ReceiptLineItem>()
}).ToList() ?? new List<ReceiptWithItems>();
await LoadAvailableCategoriesAsync();
// Check if current category exists in list
UseCustomCategory = !string.IsNullOrWhiteSpace(Transaction.Category)
&& !AvailableCategories.Contains(Transaction.Category);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
await LoadDataAsync();
return Page();
}
var transaction = await _db.Transactions.FindAsync(Transaction.Id);
if (transaction == null)
return NotFound();
// Update category and notes
transaction.Category = string.IsNullOrWhiteSpace(Transaction.Category)
? ""
: Transaction.Category.Trim();
transaction.Notes = string.IsNullOrWhiteSpace(Transaction.Notes)
? ""
: Transaction.Notes.Trim();
await _db.SaveChangesAsync();
SuccessMessage = "Transaction updated successfully!";
return RedirectToPage(new { id = Transaction.Id });
}
public async Task<IActionResult> OnPostUploadReceiptAsync()
{
if (ReceiptFile == null)
{
ErrorMessage = "Please select a file to upload.";
await LoadDataAsync();
return Page();
}
var result = await _receiptManager.UploadReceiptAsync(Transaction.Id, ReceiptFile);
if (result.IsSuccess)
{
SuccessMessage = "Receipt uploaded successfully!";
}
else
{
ErrorMessage = result.ErrorMessage;
}
return RedirectToPage(new { id = Transaction.Id });
}
public async Task<IActionResult> OnPostDeleteReceiptAsync(long receiptId)
{
var success = await _receiptManager.DeleteReceiptAsync(receiptId);
if (success)
{
SuccessMessage = "Receipt deleted successfully!";
}
else
{
ErrorMessage = "Failed to delete receipt.";
}
return RedirectToPage(new { id = Transaction.Id });
}
public async Task<IActionResult> OnPostParseReceiptAsync(long receiptId)
{
var result = await _receiptParser.ParseReceiptAsync(receiptId);
if (result.IsSuccess)
{
SuccessMessage = result.Message;
}
else
{
ErrorMessage = result.Message;
}
return RedirectToPage(new { id = Transaction.Id });
}
private async Task LoadDataAsync()
{
await LoadAvailableCategoriesAsync();
var transaction = await _db.Transactions
.Include(t => t.Receipts)
.ThenInclude(r => r.LineItems)
.FirstOrDefaultAsync(t => t.Id == Transaction.Id);
if (transaction != null)
{
Receipts = transaction.Receipts?.Select(r => new ReceiptWithItems
{
Receipt = r,
LineItems = r.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new List<ReceiptLineItem>()
}).ToList() ?? new List<ReceiptWithItems>();
}
}
private async Task LoadAvailableCategoriesAsync()
{
AvailableCategories = await _db.Transactions
.Select(t => t.Category ?? "")
.Where(c => !string.IsNullOrWhiteSpace(c))
.Distinct()
.OrderBy(c => c)
.ToListAsync();
}
public class TransactionEditModel
{
public long Id { get; set; }
public DateTime Date { get; set; }
public string Name { get; set; } = "";
public string Memo { get; set; } = "";
public decimal Amount { get; set; }
public string Category { get; set; } = "";
public string Notes { get; set; } = "";
public string CardLabel { get; set; } = "";
}
public class ReceiptWithItems
{
public Receipt Receipt { get; set; } = null!;
public List<ReceiptLineItem> LineItems { get; set; } = new();
}
}
}

View File

@@ -0,0 +1,26 @@
@page
@model ErrorModel
@{
ViewData["Title"] = "Error";
}
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (Model.ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@Model.RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

View File

@@ -0,0 +1,28 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MoneyMap.Pages
{
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
private readonly ILogger<ErrorModel> _logger;
public ErrorModel(ILogger<ErrorModel> logger)
{
_logger = logger;
}
public void OnGet()
{
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
}
}
}

141
MoneyMap/Pages/Index.cshtml Normal file
View File

@@ -0,0 +1,141 @@
@page
@model MoneyMap.Pages.IndexModel
@{
ViewData["Title"] = "MoneyMap";
}
<div class="row g-3">
<div class="col-sm-6 col-lg-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Transactions</div>
<div class="fs-3 fw-bold">@Model.Stats.TotalTransactions</div>
<div class="small text-muted">Credits: @Model.Stats.Credits · Debits: @Model.Stats.Debits</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Uncategorized</div>
<div class="fs-3 fw-bold">@Model.Stats.Uncategorized</div>
<div class="small text-muted">
@if (Model.Stats.Uncategorized > 0)
{
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none">View uncategorized →</a>
}
else
{
<span>All categorized!</span>
}
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Receipts</div>
<div class="fs-3 fw-bold">@Model.Stats.Receipts</div>
<div class="small text-muted">Linked files</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Cards</div>
<div class="fs-3 fw-bold">@Model.Stats.Cards</div>
<div class="small text-muted">Tracked accounts</div>
</div>
</div>
</div>
</div>
<div class="my-3 d-flex gap-2">
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a>
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a>
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a>
</div>
@if (Model.TopCategories.Any())
{
<div class="card shadow-sm mb-3">
<div class="card-header">Top expense categories (last 90 days)</div>
<div class="card-body p-0">
<table class="table table-sm mb-0 table-hover">
<thead>
<tr>
<th>Category</th>
<th class="text-end">Total Spend</th>
<th class="text-end">Txns</th>
</tr>
</thead>
<tbody>
@foreach (var c in Model.TopCategories)
{
<tr style="cursor: pointer;" onclick="window.location.href='@Url.Page("/Transactions", new { category = string.IsNullOrWhiteSpace(c.Category) ? "(blank)" : c.Category })'">
<td>
<a asp-page="/Transactions" asp-route-category="@(string.IsNullOrWhiteSpace(c.Category) ? "(blank)" : c.Category)" class="text-decoration-none text-dark">
@(string.IsNullOrWhiteSpace(c.Category) ? "(uncategorized)" : c.Category)
</a>
</td>
<td class="text-end">@c.TotalSpend.ToString("C")</td>
<td class="text-end">@c.Count</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="card shadow-sm">
<div class="card-header">Recent transactions</div>
<div class="card-body p-0">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th style="width: 110px;">Date</th>
<th>Name</th>
<th>Memo</th>
<th style="width: 110px;" class="text-end">Amount</th>
<th style="width: 160px;">Category</th>
<th style="width: 110px;">Card</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model.Recent)
{
<tr>
<td>@t.Date.ToString("yyyy-MM-dd")</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>@t.Name</span>
@if (t.ReceiptCount > 0)
{
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
📄 @t.ReceiptCount
</span>
}
</div>
</td>
<td class="text-truncate" style="max-width:320px">@t.Memo</td>
<td class="text-end">@t.Amount.ToString("C")</td>
<td>
@if (string.IsNullOrWhiteSpace(t.Category))
{
<a asp-page="/Transactions" asp-route-category="(blank)" class="text-decoration-none text-muted">(uncategorized)</a>
}
else
{
<a asp-page="/Transactions" asp-route-category="@t.Category" class="text-decoration-none text-dark">@t.Category</a>
}
</td>
<td>@t.CardLabel</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,251 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class IndexModel : PageModel
{
private readonly IDashboardService _dashboardService;
public IndexModel(IDashboardService dashboardService)
{
_dashboardService = dashboardService;
}
public DashboardStats Stats { get; set; } = new();
public List<TopCategoryRow> TopCategories { get; set; } = new();
public List<RecentTxnRow> Recent { get; set; } = new();
public async Task OnGet()
{
var dashboard = await _dashboardService.GetDashboardDataAsync();
Stats = dashboard.Stats;
TopCategories = dashboard.TopCategories;
Recent = dashboard.RecentTransactions;
}
public record DashboardStats(
int TotalTransactions = 0,
int Credits = 0,
int Debits = 0,
int Uncategorized = 0,
int Receipts = 0,
int Cards = 0);
public class TopCategoryRow
{
public string Category { get; set; } = "";
public decimal TotalSpend { get; set; }
public int Count { get; set; }
}
public class RecentTxnRow
{
public DateTime Date { get; set; }
public string Name { get; set; } = "";
public string Memo { get; set; } = "";
public decimal Amount { get; set; }
public string Category { get; set; } = "";
public string CardLabel { get; set; } = "";
public int ReceiptCount { get; set; }
}
}
// ===== Service Layer =====
public interface IDashboardService
{
Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20);
}
public class DashboardService : IDashboardService
{
private readonly MoneyMapContext _db;
private readonly IDashboardStatsCalculator _statsCalculator;
private readonly ITopCategoriesProvider _topCategoriesProvider;
private readonly IRecentTransactionsProvider _recentTransactionsProvider;
public DashboardService(
MoneyMapContext db,
IDashboardStatsCalculator statsCalculator,
ITopCategoriesProvider topCategoriesProvider,
IRecentTransactionsProvider recentTransactionsProvider)
{
_db = db;
_statsCalculator = statsCalculator;
_topCategoriesProvider = topCategoriesProvider;
_recentTransactionsProvider = recentTransactionsProvider;
}
public async Task<DashboardData> GetDashboardDataAsync(int topCategoriesCount = 8, int recentTransactionsCount = 20)
{
var stats = await _statsCalculator.CalculateAsync();
var topCategories = await _topCategoriesProvider.GetTopCategoriesAsync(topCategoriesCount);
var recent = await _recentTransactionsProvider.GetRecentTransactionsAsync(recentTransactionsCount);
return new DashboardData
{
Stats = stats,
TopCategories = topCategories,
RecentTransactions = recent
};
}
}
// ===== Stats Calculator =====
public interface IDashboardStatsCalculator
{
Task<IndexModel.DashboardStats> CalculateAsync();
}
public class DashboardStatsCalculator : IDashboardStatsCalculator
{
private readonly MoneyMapContext _db;
public DashboardStatsCalculator(MoneyMapContext db)
{
_db = db;
}
public async Task<IndexModel.DashboardStats> CalculateAsync()
{
var transactionStats = await GetTransactionStatsAsync();
var receiptsCount = await _db.Receipts.CountAsync();
var cardsCount = await _db.Cards.CountAsync();
return new IndexModel.DashboardStats(
transactionStats.Total,
transactionStats.Credits,
transactionStats.Debits,
transactionStats.Uncategorized,
receiptsCount,
cardsCount
);
}
private async Task<TransactionStats> GetTransactionStatsAsync()
{
var stats = await _db.Transactions
.GroupBy(_ => 1)
.Select(g => new TransactionStats
{
Total = g.Count(),
Credits = g.Count(t => t.Amount > 0),
Debits = g.Count(t => t.Amount < 0),
Uncategorized = g.Count(t => t.Category == null || t.Category == "")
})
.FirstOrDefaultAsync();
return stats ?? new TransactionStats();
}
private class TransactionStats
{
public int Total { get; set; }
public int Credits { get; set; }
public int Debits { get; set; }
public int Uncategorized { get; set; }
}
}
// ===== Top Categories Provider =====
public interface ITopCategoriesProvider
{
Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90);
}
public class TopCategoriesProvider : ITopCategoriesProvider
{
private readonly MoneyMapContext _db;
public TopCategoriesProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<List<IndexModel.TopCategoryRow>> GetTopCategoriesAsync(int count = 8, int lastDays = 90)
{
var since = DateTime.UtcNow.Date.AddDays(-lastDays);
return await _db.Transactions
.Where(t => t.Date >= since && t.Amount < 0)
.GroupBy(t => t.Category ?? "")
.Select(g => new IndexModel.TopCategoryRow
{
Category = g.Key,
TotalSpend = g.Sum(x => -x.Amount),
Count = g.Count()
})
.OrderByDescending(x => x.TotalSpend)
.Take(count)
.AsNoTracking()
.ToListAsync();
}
}
// ===== Recent Transactions Provider =====
public interface IRecentTransactionsProvider
{
Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20);
}
public class RecentTransactionsProvider : IRecentTransactionsProvider
{
private readonly MoneyMapContext _db;
public RecentTransactionsProvider(MoneyMapContext db)
{
_db = db;
}
public async Task<List<IndexModel.RecentTxnRow>> GetRecentTransactionsAsync(int count = 20)
{
return await _db.Transactions
.Include(t => t.Card)
.OrderByDescending(t => t.Date)
.ThenByDescending(t => t.Id)
.Select(t => new IndexModel.RecentTxnRow
{
Date = t.Date,
Name = t.Name,
Memo = t.Memo,
Amount = t.Amount,
Category = t.Category ?? "",
CardLabel = FormatCardLabel(t.Card, t.CardLast4)
})
.Take(count)
.AsNoTracking()
.ToListAsync();
}
private static string FormatCardLabel(Card? card, string? cardLast4)
{
if (card != null)
return $"{card.Issuer} {card.Last4}";
if (string.IsNullOrEmpty(cardLast4))
return "";
return $"•••• {cardLast4}";
}
}
// ===== Data Transfer Objects =====
public class DashboardData
{
public required IndexModel.DashboardStats Stats { get; init; }
public required List<IndexModel.TopCategoryRow> TopCategories { get; init; }
public required List<IndexModel.RecentTxnRow> RecentTransactions { get; init; }
}
}

View File

@@ -0,0 +1,8 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>

View File

@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MoneyMap.Pages
{
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
}

View File

@@ -0,0 +1,103 @@
@page
@model MoneyMap.Pages.RecategorizeModel
@{
ViewData["Title"] = "Recategorize Transactions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Recategorize Transactions</h2>
<div>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row mb-4">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Total Transactions</div>
<div class="fs-3 fw-bold">@Model.Stats.TotalTransactions</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Categorized</div>
<div class="fs-3 fw-bold text-success">@Model.Stats.Categorized</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted">Uncategorized</div>
<div class="fs-3 fw-bold text-warning">@Model.Stats.Uncategorized</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Auto-Categorize Existing Transactions</strong>
</div>
<div class="card-body">
<p>Use the category mappings to automatically categorize transactions that are already in your database.</p>
<div class="row g-3">
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<h5 class="mb-3">Categorize Uncategorized Only</h5>
<p class="text-muted small">
Only process transactions with empty categories. Safe option that won't overwrite existing categories.
</p>
<form method="post" asp-page-handler="RecategorizeUncategorized"
onsubmit="return confirm('Categorize @Model.Stats.Uncategorized uncategorized transactions?');">
<button type="submit" class="btn btn-primary" @(Model.Stats.Uncategorized == 0 ? "disabled" : "")>
Categorize @Model.Stats.Uncategorized Transactions
</button>
</form>
</div>
</div>
<div class="col-md-6">
<div class="border rounded p-3 h-100">
<h5 class="mb-3">Recategorize All Transactions</h5>
<p class="text-muted small">
<strong>Warning:</strong> This will overwrite ALL existing categories based on current mapping rules. Use if you've updated your category mappings.
</p>
<form method="post" asp-page-handler="RecategorizeAll"
onsubmit="return confirm('⚠️ This will recategorize ALL @Model.Stats.TotalTransactions transactions. Are you sure?');">
<button type="submit" class="btn btn-warning" @(Model.Stats.TotalTransactions == 0 ? "disabled" : "")>
Recategorize All @Model.Stats.TotalTransactions Transactions
</button>
</form>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<strong>How It Works</strong>
</div>
<div class="card-body">
<ul class="mb-0">
<li>Transactions are matched against your <a asp-page="/CategoryMappings">category mapping patterns</a></li>
<li>If a merchant name matches a pattern, the transaction gets that category</li>
<li>Special rule: Gas stations with purchases under $20 → "Convenience Store"</li>
<li>Transactions with no matching pattern remain uncategorized for manual review</li>
<li>The process updates the database immediately - make sure you have backups if needed</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,130 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class RecategorizeModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionCategorizer _categorizer;
public RecategorizeModel(MoneyMapContext db, ITransactionCategorizer categorizer)
{
_db = db;
_categorizer = categorizer;
}
public RecategorizeStats Stats { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
public async Task OnGetAsync()
{
await LoadStatsAsync();
}
public async Task<IActionResult> OnPostRecategorizeAllAsync()
{
var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: true);
SuccessMessage = $"Processed {result.Total} transactions: {result.Updated} updated, {result.AlreadyCorrect} already correct, {result.NoMatch} no match found.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostRecategorizeUncategorizedAsync()
{
var result = await RecategorizeTransactionsAsync(includeAlreadyCategorized: false);
SuccessMessage = $"Processed {result.Total} uncategorized transactions: {result.Updated} categorized, {result.NoMatch} still need manual categorization.";
return RedirectToPage();
}
private async Task LoadStatsAsync()
{
var totalTransactions = await _db.Transactions.CountAsync();
var uncategorized = await _db.Transactions
.CountAsync(t => string.IsNullOrWhiteSpace(t.Category));
var categorized = totalTransactions - uncategorized;
Stats = new RecategorizeStats
{
TotalTransactions = totalTransactions,
Categorized = categorized,
Uncategorized = uncategorized
};
}
private async Task<RecategorizeResult> RecategorizeTransactionsAsync(bool includeAlreadyCategorized)
{
var query = _db.Transactions.AsQueryable();
if (!includeAlreadyCategorized)
{
query = query.Where(t => string.IsNullOrWhiteSpace(t.Category));
}
var transactions = await query.ToListAsync();
int total = transactions.Count;
int updated = 0;
int alreadyCorrect = 0;
int noMatch = 0;
foreach (var txn in transactions)
{
var newCategory = await _categorizer.CategorizeAsync(txn.Name, txn.Amount);
if (string.IsNullOrWhiteSpace(newCategory))
{
noMatch++;
continue;
}
if (txn.Category == newCategory)
{
alreadyCorrect++;
continue;
}
txn.Category = newCategory;
updated++;
}
if (updated > 0)
{
await _db.SaveChangesAsync();
}
return new RecategorizeResult
{
Total = total,
Updated = updated,
AlreadyCorrect = alreadyCorrect,
NoMatch = noMatch
};
}
public class RecategorizeStats
{
public int TotalTransactions { get; set; }
public int Categorized { get; set; }
public int Uncategorized { get; set; }
}
private class RecategorizeResult
{
public int Total { get; set; }
public int Updated { get; set; }
public int AlreadyCorrect { get; set; }
public int NoMatch { get; set; }
}
}
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - MoneyMap</title>
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
<link rel="stylesheet" href="~/MoneyMap.styles.css" asp-append-version="true" />
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand fw-bold" asp-page="/Index">MoneyMap</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Index">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Transactions">Transactions</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Upload">Upload CSV</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/CategoryMappings">Categories</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-page="/Recategorize">Recategorize</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<main role="main" class="pb-3">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2025 - MoneyMap - <a asp-area="" asp-page="/Privacy">Privacy</a>
</div>
</footer>
<script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/bootstrap.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,48 @@
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
for details on configuring this project to bundle and minify static web assets. */
a.navbar-brand {
white-space: normal;
text-align: center;
word-break: break-all;
}
a {
color: #0077cc;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.border-top {
border-top: 1px solid #e5e5e5;
}
.border-bottom {
border-bottom: 1px solid #e5e5e5;
}
.box-shadow {
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
}
button.accept-policy {
font-size: 1rem;
line-height: inherit;
}
.footer {
position: absolute;
bottom: 0;
width: 100%;
white-space: nowrap;
line-height: 60px;
}

View File

@@ -0,0 +1,2 @@
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>

View File

@@ -0,0 +1,193 @@
@page
@model MoneyMap.Pages.TransactionsModel
@{
ViewData["Title"] = "Transactions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Transactions</h2>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
<!-- Filters -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label for="Category" class="form-label">Category</label>
<select asp-for="Category" class="form-select">
<option value="">All Categories</option>
@foreach (var cat in Model.AvailableCategories)
{
<option value="@cat">@(string.IsNullOrWhiteSpace(cat) ? "(blank)" : cat)</option>
}
</select>
</div>
<div class="col-md-3">
<label for="CardId" class="form-label">Card</label>
<select asp-for="CardId" class="form-select">
<option value="">All Cards</option>
@foreach (var card in Model.AvailableCards)
{
<option value="@card.Id">@card.Owner - @card.Last4</option>
}
</select>
</div>
<div class="col-md-2">
<label for="StartDate" class="form-label">Start Date</label>
<input asp-for="StartDate" type="date" class="form-control" />
</div>
<div class="col-md-2">
<label for="EndDate" class="form-label">End Date</label>
<input asp-for="EndDate" type="date" class="form-control" />
</div>
<div class="col-md-2 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Filter</button>
</div>
</form>
@if (!string.IsNullOrWhiteSpace(Model.Category) || !string.IsNullOrWhiteSpace(Model.CardId) || Model.StartDate.HasValue || Model.EndDate.HasValue)
{
<div class="mt-2">
<a asp-page="/Transactions" class="btn btn-sm btn-outline-secondary">Clear Filters</a>
</div>
}
</div>
</div>
<!-- Stats -->
<div class="row g-3 mb-3">
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Transactions</div>
<div class="fs-4 fw-bold">@Model.Stats.Count</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Total Spent</div>
<div class="fs-4 fw-bold text-danger">@Model.Stats.TotalDebits.ToString("C")</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Total Income</div>
<div class="fs-4 fw-bold text-success">@Model.Stats.TotalCredits.ToString("C")</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Net</div>
<div class="fs-4 fw-bold @(Model.Stats.NetAmount >= 0 ? "text-success" : "text-danger")">
@Model.Stats.NetAmount.ToString("C")
</div>
</div>
</div>
</div>
</div>
<!-- Transactions Table -->
@if (Model.Transactions.Any())
{
<div class="card shadow-sm">
<div class="card-header">
@if (!string.IsNullOrWhiteSpace(Model.Category))
{
<strong>@Model.Category</strong>
<span class="text-muted">- @Model.Stats.Count transactions</span>
}
else
{
<strong>All Transactions</strong>
<span class="text-muted">- @Model.Stats.Count total</span>
}
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th style="width: 110px;">Date</th>
<th>Name</th>
<th>Memo</th>
<th style="width: 110px;" class="text-end">Amount</th>
<th style="width: 160px;">Category</th>
<th style="width: 110px;">Card</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var t in Model.Transactions)
{
<tr>
<td>@t.Date.ToString("yyyy-MM-dd")</td>
<td>
<div class="d-flex align-items-center gap-2">
<span>@t.Name</span>
@if (t.ReceiptCount > 0)
{
<span class="badge bg-success" title="@t.ReceiptCount receipt(s) attached">
📄 @t.ReceiptCount
</span>
}
@if (!string.IsNullOrWhiteSpace(t.Notes))
{
<span class="badge bg-info"
title="@t.Notes"
data-bs-toggle="tooltip"
data-bs-placement="top">
📝
</span>
}
</div>
</td>
<td class="text-truncate" style="max-width:320px">@t.Memo</td>
<td class="text-end @(t.Amount >= 0 ? "text-success" : "")">
@t.Amount.ToString("C")
</td>
<td>
@if (string.IsNullOrWhiteSpace(t.Category))
{
<span class="text-muted">(uncategorized)</span>
}
else
{
@t.Category
}
</td>
<td>@t.CardLabel</td>
<td>
<a asp-page="/EditTransaction" asp-route-id="@t.Id" class="btn btn-sm btn-outline-primary" title="Edit">
Edit
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
else
{
<div class="alert alert-info">
No transactions found matching the selected filters.
</div>
}
@section Scripts {
<script>
// Initialize Bootstrap tooltips for notes badges
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
</script>
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Pages
{
public class TransactionsModel : PageModel
{
private readonly MoneyMapContext _db;
public TransactionsModel(MoneyMapContext db)
{
_db = db;
}
[BindProperty(SupportsGet = true)]
public string? Category { get; set; }
[BindProperty(SupportsGet = true)]
public string? CardId { get; set; }
[BindProperty(SupportsGet = true)]
public DateTime? StartDate { get; set; }
[BindProperty(SupportsGet = true)]
public DateTime? EndDate { get; set; }
public List<TransactionRow> Transactions { get; set; } = new();
public List<string> AvailableCategories { get; set; } = new();
public List<Card> AvailableCards { get; set; } = new();
public TransactionStats Stats { get; set; } = new();
public async Task OnGetAsync()
{
var query = _db.Transactions.Include(t => t.Card).AsQueryable();
// Apply filters
if (!string.IsNullOrWhiteSpace(Category))
{
if (Category == "(blank)")
{
query = query.Where(t => string.IsNullOrWhiteSpace(t.Category));
}
else
{
query = query.Where(t => t.Category == Category);
}
}
if (!string.IsNullOrWhiteSpace(CardId) && int.TryParse(CardId, out int cardIdInt))
{
query = query.Where(t => t.CardId == cardIdInt);
}
if (StartDate.HasValue)
{
query = query.Where(t => t.Date >= StartDate.Value);
}
if (EndDate.HasValue)
{
query = query.Where(t => t.Date <= EndDate.Value);
}
// Get transactions
var transactions = await query
.OrderByDescending(t => t.Date)
.ThenByDescending(t => t.Id)
.ToListAsync();
Transactions = transactions.Select(t => new TransactionRow
{
Id = t.Id,
Date = t.Date,
Name = t.Name,
Memo = t.Memo,
Amount = t.Amount,
Category = t.Category ?? "",
Notes = t.Notes ?? "",
CardLabel = t.Card != null
? $"{t.Card.Issuer} {t.Card.Last4}"
: (string.IsNullOrEmpty(t.CardLast4) ? "" : $"•••• {t.CardLast4}"),
ReceiptCount = t.Receipts?.Count ?? 0
}).ToList();
// Calculate stats for filtered results
Stats = new TransactionStats
{
Count = transactions.Count,
TotalDebits = transactions.Where(t => t.Amount < 0).Sum(t => t.Amount),
TotalCredits = transactions.Where(t => t.Amount > 0).Sum(t => t.Amount),
NetAmount = transactions.Sum(t => t.Amount)
};
// Get available categories for filter dropdown
AvailableCategories = await _db.Transactions
.Select(t => t.Category ?? "")
.Distinct()
.OrderBy(c => c)
.ToListAsync();
// Get available cards for filter dropdown
AvailableCards = await _db.Cards
.OrderBy(c => c.Owner)
.ThenBy(c => c.Last4)
.ToListAsync();
}
public class TransactionRow
{
public long Id { get; set; }
public DateTime Date { get; set; }
public string Name { get; set; } = "";
public string Memo { get; set; } = "";
public decimal Amount { get; set; }
public string Category { get; set; } = "";
public string Notes { get; set; } = "";
public string CardLabel { get; set; } = "";
public int ReceiptCount { get; set; }
}
public class TransactionStats
{
public int Count { get; set; }
public decimal TotalDebits { get; set; }
public decimal TotalCredits { get; set; }
public decimal NetAmount { get; set; }
}
}
}

View File

@@ -0,0 +1,48 @@
@page
@model MoneyMap.Pages.UploadModel
@{
ViewData["Title"] = "Upload Transactions";
}
<h2>Upload Transactions</h2>
<form method="post" enctype="multipart/form-data" class="vstack gap-3">
@Html.AntiForgeryToken()
<div>
<label class="form-label">CSV file</label>
<input asp-for="Csv" type="file" class="form-control" accept=".csv" />
<span asp-validation-for="Csv" class="text-danger"></span>
</div>
<fieldset class="border rounded p-3">
<legend class="float-none w-auto fs-6">Card association</legend>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="CardMode" value="Auto" checked />
<label class="form-check-label">Automatically determine</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" asp-for="CardMode" value="Manual" />
<label class="form-check-label">Select from list</label>
</div>
<div id="cardSelectRow" class="mt-2">
<label class="form-label">Card</label>
<select asp-for="SelectedCardId" class="form-select">
<option value="">-- choose card --</option>
@foreach (var c in Model.Cards)
{
<option value="@c.Id">@c.Issuer @c.Last4 (@c.Owner)</option>
}
</select>
<span asp-validation-for="SelectedCardId" class="text-danger"></span>
</div>
</fieldset>
<button type="submit" class="btn btn-primary">Upload</button>
<div asp-validation-summary="All" class="text-danger"></div>
</form>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@@ -0,0 +1,334 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Models.Import;
namespace MoneyMap.Pages
{
public class UploadModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionImporter _importer;
public UploadModel(MoneyMapContext db, ITransactionImporter importer)
{
_db = db;
_importer = importer;
}
[BindProperty] public IFormFile? Csv { get; set; }
[BindProperty] public CardSelectMode CardMode { get; set; } = CardSelectMode.Auto;
[BindProperty] public int? SelectedCardId { get; set; }
public List<Card> Cards { get; set; } = new();
public ImportResult? Result { get; set; }
public async Task OnGetAsync()
{
Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ValidateInput())
{
await OnGetAsync();
return Page();
}
Cards = await _db.Cards.OrderBy(c => c.Owner).ThenBy(c => c.Last4).ToListAsync();
var importContext = new ImportContext
{
CardMode = CardMode,
SelectedCardId = SelectedCardId,
AvailableCards = Cards,
FileName = Csv!.FileName
};
var result = await _importer.ImportAsync(Csv!.OpenReadStream(), importContext);
if (!result.IsSuccess)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage!);
await OnGetAsync();
return Page();
}
Result = result.Data;
return Page();
}
private bool ValidateInput()
{
if (Csv is null || Csv.Length == 0)
{
ModelState.AddModelError(nameof(Csv), "Please choose a CSV file.");
return false;
}
return true;
}
public record ImportResult(int Total, int Inserted, int Skipped, string? Last4FromFile);
public enum CardSelectMode { Auto, Manual }
}
// ===== Service Layer =====
public interface ITransactionImporter
{
Task<ImportOperationResult> ImportAsync(Stream csvStream, ImportContext context);
}
public class TransactionImporter : ITransactionImporter
{
private readonly MoneyMapContext _db;
private readonly ICardResolver _cardResolver;
public TransactionImporter(MoneyMapContext db, ICardResolver cardResolver)
{
_db = db;
_cardResolver = cardResolver;
}
public async Task<ImportOperationResult> ImportAsync(Stream csvStream, ImportContext context)
{
var stats = new ImportStats();
var addedInThisBatch = new HashSet<TransactionKey>();
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
HeaderValidated = null,
MissingFieldFound = null
});
csv.Read();
csv.ReadHeader();
var hasCategory = csv.HeaderRecord?.Any(h => h.Equals("Category", StringComparison.OrdinalIgnoreCase)) ?? false;
csv.Context.RegisterClassMap(new TransactionCsvRowMap(hasCategory));
while (csv.Read())
{
var row = csv.GetRecord<TransactionCsvRow>();
stats.Total++;
var cardResolution = await _cardResolver.ResolveCardAsync(row.Memo, context);
if (!cardResolution.IsSuccess)
return ImportOperationResult.Failure(cardResolution.ErrorMessage!);
var transaction = MapToTransaction(row, cardResolution.CardId, cardResolution.CardLast4!);
var key = new TransactionKey(transaction);
// Check both database AND current batch for duplicates
if (addedInThisBatch.Contains(key) || await IsDuplicate(transaction))
{
stats.Skipped++;
continue;
}
_db.Transactions.Add(transaction);
addedInThisBatch.Add(key);
stats.Inserted++;
}
await _db.SaveChangesAsync();
var result = new UploadModel.ImportResult(
stats.Total,
stats.Inserted,
stats.Skipped,
CardIdentifierExtractor.FromFileName(context.FileName)
);
return ImportOperationResult.Success(result);
}
private async Task<bool> IsDuplicate(Transaction txn)
{
return await _db.Transactions.AnyAsync(t =>
t.Date == txn.Date &&
t.Amount == txn.Amount &&
t.Name == txn.Name &&
t.Memo == txn.Memo &&
t.CardId == txn.CardId);
}
private static Transaction MapToTransaction(TransactionCsvRow row, int cardId, string cardLast4)
{
return new Transaction
{
Date = row.Date,
TransactionType = row.Transaction?.Trim() ?? "",
Name = row.Name?.Trim() ?? "",
Memo = row.Memo?.Trim() ?? "",
Amount = row.Amount,
Category = (row.Category ?? "").Trim(),
CardLast4 = cardLast4,
CardId = cardId
};
}
}
// ===== Card Resolution =====
public interface ICardResolver
{
Task<CardResolutionResult> ResolveCardAsync(string? memo, ImportContext context);
}
public class CardResolver : ICardResolver
{
private readonly MoneyMapContext _db;
public CardResolver(MoneyMapContext db)
{
_db = db;
}
public async Task<CardResolutionResult> ResolveCardAsync(string? memo, ImportContext context)
{
if (context.CardMode == UploadModel.CardSelectMode.Manual)
return ResolveManually(context);
return await ResolveAutomaticallyAsync(memo, context);
}
private CardResolutionResult ResolveManually(ImportContext context)
{
if (context.SelectedCardId is null)
return CardResolutionResult.Failure("Pick a card or switch to Auto.");
var card = context.AvailableCards.FirstOrDefault(c => c.Id == context.SelectedCardId);
if (card is null)
return CardResolutionResult.Failure("Selected card not found.");
return CardResolutionResult.Success(card.Id, card.Last4);
}
private async Task<CardResolutionResult> ResolveAutomaticallyAsync(string? memo, ImportContext context)
{
var last4 = CardIdentifierExtractor.FromMemo(memo)
?? CardIdentifierExtractor.FromFileName(context.FileName);
if (string.IsNullOrWhiteSpace(last4))
return CardResolutionResult.Failure(
"Couldn't determine card from memo or file name. Choose a card manually.");
var card = context.AvailableCards.FirstOrDefault(c => c.Last4 == last4);
if (card is null)
{
// Auto-create the card
card = new Card
{
Last4 = last4,
Owner = "Unknown" // You can adjust this default
};
_db.Cards.Add(card);
await _db.SaveChangesAsync();
// Add to context so subsequent rows can use it
context.AvailableCards.Add(card);
}
return CardResolutionResult.Success(card.Id, card.Last4);
}
}
// ===== Helper Classes =====
public static class CardIdentifierExtractor
{
private static readonly Regex MemoLast4Pattern = new(@"\b(?:\.|\s)(\d{4,6})\b", RegexOptions.Compiled);
private const int Last4Length = 4;
public static string? FromMemo(string? memo)
{
if (string.IsNullOrWhiteSpace(memo))
return null;
var match = MemoLast4Pattern.Match(memo);
if (!match.Success)
return null;
var digits = match.Groups[1].Value;
return digits.Length >= Last4Length ? digits[^Last4Length..] : null;
}
public static string? FromFileName(string fileName)
{
var name = Path.GetFileNameWithoutExtension(fileName);
var parts = name.Split(new[] { '-', '_', ' ' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts.Select(p => p.Trim()))
{
if (part.Length == Last4Length && int.TryParse(part, out _))
return part;
}
return null;
}
}
// ===== Data Transfer Objects =====
public record TransactionKey(DateTime Date, decimal Amount, string Name, string Memo, int CardId)
{
public TransactionKey(Transaction txn)
: this(txn.Date, txn.Amount, txn.Name, txn.Memo, txn.CardId) { }
}
public class ImportContext
{
public required UploadModel.CardSelectMode CardMode { get; init; }
public int? SelectedCardId { get; init; }
public required List<Card> AvailableCards { get; init; }
public required string FileName { get; init; }
}
public class ImportStats
{
public int Total { get; set; }
public int Inserted { get; set; }
public int Skipped { get; set; }
}
public class CardResolutionResult
{
public bool IsSuccess { get; init; }
public int CardId { get; init; }
public string? CardLast4 { get; init; }
public string? ErrorMessage { get; init; }
public static CardResolutionResult Success(int cardId, string cardLast4) =>
new() { IsSuccess = true, CardId = cardId, CardLast4 = cardLast4 };
public static CardResolutionResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error };
}
public class ImportOperationResult
{
public bool IsSuccess { get; init; }
public UploadModel.ImportResult? Data { get; init; }
public string? ErrorMessage { get; init; }
public static ImportOperationResult Success(UploadModel.ImportResult data) =>
new() { IsSuccess = true, Data = data };
public static ImportOperationResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error };
}
}

View File

@@ -0,0 +1,220 @@
@page "{id:long}"
@model MoneyMap.Pages.ViewReceiptModel
@{
ViewData["Title"] = "View Receipt";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Receipt Details</h2>
<a asp-page="/EditTransaction" asp-route-id="@Model.Receipt.TransactionId" class="btn btn-outline-secondary">
Back to Transaction
</a>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row">
<!-- Left column - Receipt Image/PDF -->
<div class="col-lg-8">
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>@Model.Receipt.FileName</strong>
</div>
<div class="card-body text-center">
@if (Model.Receipt.ContentType == "application/pdf")
{
<iframe src="@Model.ReceiptUrl"
style="width: 100%; height: 800px; border: 1px solid #ddd;"
title="Receipt PDF">
</iframe>
}
else
{
<img src="@Model.ReceiptUrl"
alt="Receipt"
class="img-fluid"
style="max-height: 800px; border: 1px solid #ddd;" />
}
</div>
</div>
</div>
<!-- Right column - Metadata and Line Items -->
<div class="col-lg-4">
<!-- Receipt Info -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Receipt Information</strong>
</div>
<div class="card-body">
<dl class="row mb-0">
@if (!string.IsNullOrWhiteSpace(Model.Receipt.Merchant))
{
<dt class="col-sm-4">Merchant</dt>
<dd class="col-sm-8">@Model.Receipt.Merchant</dd>
}
@if (Model.Receipt.ReceiptDate.HasValue)
{
<dt class="col-sm-4">Date</dt>
<dd class="col-sm-8">@Model.Receipt.ReceiptDate.Value.ToString("MMM d, yyyy")</dd>
}
@if (Model.Receipt.Subtotal.HasValue)
{
<dt class="col-sm-4">Subtotal</dt>
<dd class="col-sm-8">@Model.Receipt.Subtotal.Value.ToString("C")</dd>
}
@if (Model.Receipt.Tax.HasValue)
{
<dt class="col-sm-4">Tax</dt>
<dd class="col-sm-8">@Model.Receipt.Tax.Value.ToString("C")</dd>
}
@if (Model.Receipt.Total.HasValue)
{
<dt class="col-sm-4">Total</dt>
<dd class="col-sm-8"><strong>@Model.Receipt.Total.Value.ToString("C")</strong></dd>
}
<dt class="col-sm-4">File Size</dt>
<dd class="col-sm-8">@((Model.Receipt.FileSizeBytes / 1024.0).ToString("F1")) KB</dd>
<dt class="col-sm-4">Uploaded</dt>
<dd class="col-sm-8">@Model.Receipt.UploadedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")</dd>
</dl>
</div>
</div>
<!-- Parser Selection -->
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Parse Receipt</strong>
</div>
<div class="card-body">
@if (Model.AvailableParsers.Any())
{
<form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id">
<div class="mb-2">
<label for="parser" class="form-label small">Select Parser</label>
<select name="parser" id="parser" class="form-select form-select-sm">
@foreach (var parser in Model.AvailableParsers)
{
<option value="@parser.FullName">@parser.Name</option>
}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm w-100">
Parse Receipt
</button>
</form>
}
else
{
<p class="text-muted small mb-0">No parsers available</p>
}
</div>
</div>
<!-- Line Items -->
@if (Model.LineItems.Any())
{
<div class="card shadow-sm mb-3">
<div class="card-header">
<strong>Line Items (@Model.LineItems.Count)</strong>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Description</th>
<th class="text-center" style="width: 60px;">Qty</th>
<th class="text-end" style="width: 80px;">Price</th>
<th class="text-end" style="width: 80px;">Total</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.LineItems)
{
<tr>
<td class="text-muted">@item.LineNumber</td>
<td>
<div class="text-truncate" style="max-width: 200px;" title="@item.Description">
@item.Description
</div>
</td>
<td class="text-center">
@(item.Quantity?.ToString("0.##") ?? "-")
</td>
<td class="text-end">
@(item.UnitPrice?.ToString("C") ?? "-")
</td>
<td class="text-end">
<strong>@(item.LineTotal?.ToString("C") ?? "-")</strong>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<!-- Parse Logs -->
@if (Model.ParseLogs.Any())
{
<div class="card shadow-sm">
<div class="card-header">
<strong>Parse History</strong>
</div>
<div class="card-body">
@foreach (var log in Model.ParseLogs)
{
<div class="border-bottom pb-2 mb-2">
<div class="d-flex justify-content-between">
<span class="fw-bold">@log.Provider (@log.Model)</span>
@if (log.Success)
{
<span class="badge bg-success">Success</span>
}
else
{
<span class="badge bg-danger">Failed</span>
}
</div>
<small class="text-muted">
@log.StartedAtUtc.ToLocalTime().ToString("MMM d, yyyy h:mm tt")
</small>
@if (log.Confidence.HasValue)
{
<div class="small">Confidence: @((log.Confidence.Value * 100).ToString("F1"))%</div>
}
@if (!string.IsNullOrWhiteSpace(log.Error))
{
<div class="small text-danger mt-1">@log.Error</div>
}
</div>
}
</div>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class ViewReceiptModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
private readonly IEnumerable<IReceiptParser> _parsers;
public ViewReceiptModel(
MoneyMapContext db,
IReceiptManager receiptManager,
IEnumerable<IReceiptParser> parsers)
{
_db = db;
_receiptManager = receiptManager;
_parsers = parsers;
}
public Receipt Receipt { get; set; } = null!;
public List<ReceiptLineItem> LineItems { get; set; } = new();
public List<ReceiptParseLog> ParseLogs { get; set; } = new();
public List<ParserOption> AvailableParsers { get; set; } = new();
public string ReceiptUrl { get; set; } = "";
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync(long id)
{
Receipt = await _db.Receipts
.Include(r => r.Transaction)
.Include(r => r.LineItems)
.Include(r => r.ParseLogs.OrderByDescending(pl => pl.StartedAtUtc))
.FirstOrDefaultAsync(r => r.Id == id);
if (Receipt == null)
return NotFound();
LineItems = Receipt.LineItems?.OrderBy(li => li.LineNumber).ToList() ?? new();
ParseLogs = Receipt.ParseLogs?.OrderByDescending(pl => pl.StartedAtUtc).ToList() ?? new();
// Get receipt URL for display - use handler parameter
ReceiptUrl = $"/ViewReceipt/{id}?handler=file";
// Get available parsers
AvailableParsers = _parsers.Select(p => new ParserOption
{
Name = p.GetType().Name.Replace("ReceiptParser", ""),
FullName = p.GetType().Name
}).ToList();
return Page();
}
public async Task<IActionResult> OnGetFileAsync(long id)
{
var receipt = await _receiptManager.GetReceiptAsync(id);
if (receipt == null)
return NotFound();
var filePath = _receiptManager.GetReceiptPhysicalPath(receipt);
if (!System.IO.File.Exists(filePath))
return NotFound("Receipt file not found on disk.");
var fileBytes = await System.IO.File.ReadAllBytesAsync(filePath);
// For PDFs, set content disposition to inline so they display in browser
if (receipt.ContentType == "application/pdf")
{
Response.Headers.Append("Content-Disposition", $"inline; filename=\"{receipt.FileName}\"");
return File(fileBytes, "application/pdf");
}
return File(fileBytes, receipt.ContentType);
}
public async Task<IActionResult> OnPostParseAsync(long id, string parser)
{
var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser);
if (selectedParser == null)
{
ErrorMessage = "Parser not found.";
return RedirectToPage(new { id });
}
var result = await selectedParser.ParseReceiptAsync(id);
if (result.IsSuccess)
{
SuccessMessage = result.Message;
}
else
{
ErrorMessage = result.Message;
}
return RedirectToPage(new { id });
}
public class ParserOption
{
public string Name { get; set; } = "";
public string FullName { get; set; } = "";
}
}
}

View File

@@ -0,0 +1,3 @@
@using MoneyMap
@namespace MoneyMap.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

44
MoneyMap/Program.cs Normal file
View File

@@ -0,0 +1,44 @@
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Pages;
using MoneyMap.Services; // Add this for the services
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddDbContext<MoneyMapContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("MoneyMapDb")));
// Add the new services here
builder.Services.AddScoped<ITransactionImporter, TransactionImporter>();
builder.Services.AddScoped<ICardResolver, CardResolver>();
builder.Services.AddScoped<ITransactionCategorizer, TransactionCategorizer>();
// Dashboard services
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IDashboardStatsCalculator, DashboardStatsCalculator>();
builder.Services.AddScoped<ITopCategoriesProvider, TopCategoriesProvider>();
builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvider>();
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.MapRazorPages();
app.Run();

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:10923",
"sslPort": 44362
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5185",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7119;http://localhost:5185",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,294 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using MoneyMap.Data;
using MoneyMap.Models;
using ImageMagick;
namespace MoneyMap.Services
{
public interface IReceiptParser
{
Task<ReceiptParseResult> ParseReceiptAsync(long receiptId);
}
public class OpenAIReceiptParser : IReceiptParser
{
private readonly MoneyMapContext _db;
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly HttpClient _httpClient;
public OpenAIReceiptParser(
MoneyMapContext db,
IWebHostEnvironment environment,
IConfiguration configuration,
HttpClient httpClient)
{
_db = db;
_environment = environment;
_configuration = configuration;
_httpClient = httpClient;
}
public async Task<ReceiptParseResult> ParseReceiptAsync(long receiptId)
{
var receipt = await _db.Receipts
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == receiptId);
if (receipt == null)
return ReceiptParseResult.Failure("Receipt not found.");
// Try environment variable first, then configuration
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? _configuration["OpenAI:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
return ReceiptParseResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json");
var filePath = Path.Combine(_environment.WebRootPath, receipt.StoragePath.Replace("/", Path.DirectorySeparatorChar.ToString()));
if (!File.Exists(filePath))
return ReceiptParseResult.Failure("Receipt file not found on disk.");
var parseLog = new ReceiptParseLog
{
ReceiptId = receiptId,
Provider = "OpenAI",
Model = "gpt-4o-mini",
StartedAtUtc = DateTime.UtcNow,
Success = false
};
try
{
string base64Data;
string mediaType;
if (receipt.ContentType == "application/pdf")
{
// Convert PDF to image using ImageMagick
base64Data = await ConvertPdfToBase64ImageAsync(filePath);
mediaType = "image/png";
}
else
{
// For images, use directly
var fileBytes = await File.ReadAllBytesAsync(filePath);
base64Data = Convert.ToBase64String(fileBytes);
mediaType = receipt.ContentType;
}
// Call OpenAI Vision API
var parseData = await CallOpenAIVisionAsync(apiKey, base64Data, mediaType);
// Update receipt with parsed data
receipt.Merchant = parseData.Merchant;
receipt.Total = parseData.Total;
receipt.Subtotal = parseData.Subtotal;
receipt.Tax = parseData.Tax;
receipt.ReceiptDate = parseData.ReceiptDate;
// Remove existing line items
var existingItems = await _db.ReceiptLineItems
.Where(li => li.ReceiptId == receiptId)
.ToListAsync();
_db.ReceiptLineItems.RemoveRange(existingItems);
// Add new line items
var lineItems = parseData.LineItems.Select((item, index) => new ReceiptLineItem
{
ReceiptId = receiptId,
LineNumber = index + 1,
Description = item.Description,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice,
LineTotal = item.LineTotal
}).ToList();
_db.ReceiptLineItems.AddRange(lineItems);
parseLog.Success = true;
parseLog.CompletedAtUtc = DateTime.UtcNow;
parseLog.Confidence = parseData.Confidence;
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
_db.ReceiptParseLogs.Add(parseLog);
await _db.SaveChangesAsync();
return ReceiptParseResult.Success($"Parsed {lineItems.Count} line items from receipt.");
}
catch (Exception ex)
{
parseLog.Error = ex.Message;
parseLog.CompletedAtUtc = DateTime.UtcNow;
_db.ReceiptParseLogs.Add(parseLog);
await _db.SaveChangesAsync();
return ReceiptParseResult.Failure($"Error parsing receipt: {ex.Message}");
}
}
private async Task<string> ConvertPdfToBase64ImageAsync(string pdfPath)
{
return await Task.Run(() =>
{
var pdfBytes = File.ReadAllBytes(pdfPath);
// Render settings: 220 DPI for good quality
var settings = new MagickReadSettings
{
Density = new Density(220),
BackgroundColor = MagickColors.White,
ColorSpace = ColorSpace.sRGB
};
using var pages = new MagickImageCollection();
pages.Read(pdfBytes, settings);
// Use first page only
if (pages.Count == 0)
throw new Exception("PDF has no pages");
using var img = (MagickImage)pages[0].Clone();
// Ensure we have a clean 8-bit RGB canvas
img.ColorType = ColorType.TrueColor;
img.Alpha(AlphaOption.Remove); // flatten onto white
img.ResetPage();
// Convert to PNG bytes
var imageBytes = img.ToByteArray(MagickFormat.Png);
return Convert.ToBase64String(imageBytes);
});
}
private async Task<ParsedReceiptData> CallOpenAIVisionAsync(string apiKey, string base64Image, string mediaType)
{
var requestBody = new
{
model = "gpt-4o-mini",
messages = new[]
{
new
{
role = "user",
content = new object[]
{
new
{
type = "text",
text = @"Analyze this receipt image and extract the following information as JSON:
{
""merchant"": ""store name"",
""receiptDate"": ""YYYY-MM-DD"" (or null if not found),
""subtotal"": 0.00 (or null if not found),
""tax"": 0.00 (or null if not found),
""total"": 0.00,
""confidence"": 0.95,
""lineItems"": [
{
""description"": ""item name"",
""quantity"": 1.0 (or null),
""unitPrice"": 0.00 (or null),
""lineTotal"": 0.00
}
]
}
Extract all line items you can see on the receipt. For each item, try to extract quantity, unit price, and line total. If any field is not clearly visible, set it to null. Respond ONLY with valid JSON, no other text."
},
new
{
type = "image_url",
image_url = new
{
url = $"data:{mediaType};base64,{base64Image}"
}
}
}
}
},
max_tokens = 2000,
temperature = 0.1
};
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("https://api.openai.com/v1/chat/completions", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
throw new Exception($"OpenAI API error ({response.StatusCode}): {errorContent}");
}
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonElement>(responseJson);
var messageContent = responseObj
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString();
// Clean up the response - remove markdown code blocks if present
messageContent = messageContent?.Trim();
if (messageContent?.StartsWith("```json") == true)
{
messageContent = messageContent.Replace("```json", "").Replace("```", "").Trim();
}
var parsedData = JsonSerializer.Deserialize<ParsedReceiptData>(messageContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return parsedData ?? new ParsedReceiptData();
}
}
public class ParsedReceiptData
{
public string? Merchant { get; set; }
public DateTime? ReceiptDate { get; set; }
public decimal? Subtotal { get; set; }
public decimal? Tax { get; set; }
public decimal? Total { get; set; }
public decimal Confidence { get; set; } = 0.5m;
public List<ParsedLineItem> LineItems { get; set; } = new();
}
public class ParsedLineItem
{
public string Description { get; set; } = "";
public decimal? Quantity { get; set; }
public decimal? UnitPrice { get; set; }
public decimal LineTotal { get; set; }
}
public class ReceiptParseResult
{
public bool IsSuccess { get; init; }
public string? Message { get; init; }
public static ReceiptParseResult Success(string message) =>
new() { IsSuccess = true, Message = message };
public static ReceiptParseResult Failure(string message) =>
new() { IsSuccess = false, Message = message };
}
}

View File

@@ -0,0 +1,155 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services
{
public interface IReceiptManager
{
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
Task<bool> DeleteReceiptAsync(long receiptId);
string GetReceiptPhysicalPath(Receipt receipt);
Task<Receipt?> GetReceiptAsync(long receiptId);
}
public class ReceiptManager : IReceiptManager
{
private readonly MoneyMapContext _db;
private readonly IWebHostEnvironment _environment;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
public ReceiptManager(MoneyMapContext db, IWebHostEnvironment environment)
{
_db = db;
_environment = environment;
}
public async Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file)
{
// Validate file
if (file == null || file.Length == 0)
return ReceiptUploadResult.Failure("No file selected.");
if (file.Length > MaxFileSize)
return ReceiptUploadResult.Failure($"File size exceeds {MaxFileSize / 1024 / 1024}MB limit.");
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(extension))
return ReceiptUploadResult.Failure($"File type {extension} not allowed. Use: {string.Join(", ", AllowedExtensions)}");
// Verify transaction exists
var transaction = await _db.Transactions.FindAsync(transactionId);
if (transaction == null)
return ReceiptUploadResult.Failure("Transaction not found.");
// Create receipts directory if it doesn't exist
var receiptsPath = Path.Combine(_environment.WebRootPath, "receipts");
if (!Directory.Exists(receiptsPath))
Directory.CreateDirectory(receiptsPath);
// Calculate SHA256 hash
string fileHash;
using (var sha256 = SHA256.Create())
{
using var stream = file.OpenReadStream();
var hashBytes = await sha256.ComputeHashAsync(stream);
fileHash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
// Check for duplicate (same transaction + same hash)
var existingReceipt = await _db.Receipts
.FirstOrDefaultAsync(r => r.TransactionId == transactionId && r.FileHashSha256 == fileHash);
if (existingReceipt != null)
return ReceiptUploadResult.Failure("This receipt has already been uploaded for this transaction.");
// Generate unique filename
var storedFileName = $"{transactionId}_{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(receiptsPath, storedFileName);
// Save file
using (var fileStream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(fileStream);
}
// Create receipt record
var receipt = new Receipt
{
TransactionId = transactionId,
FileName = file.FileName,
StoragePath = $"receipts/{storedFileName}",
FileSizeBytes = file.Length,
ContentType = file.ContentType,
FileHashSha256 = fileHash,
UploadedAtUtc = DateTime.UtcNow
};
_db.Receipts.Add(receipt);
await _db.SaveChangesAsync();
return ReceiptUploadResult.Success(receipt);
}
public async Task<bool> DeleteReceiptAsync(long receiptId)
{
var receipt = await _db.Receipts.FindAsync(receiptId);
if (receipt == null)
return false;
// Delete physical file
var filePath = GetReceiptPhysicalPath(receipt);
if (File.Exists(filePath))
{
try
{
File.Delete(filePath);
}
catch
{
// Continue even if file delete fails
}
}
// Delete database record (cascade will handle ParseLogs and LineItems)
_db.Receipts.Remove(receipt);
await _db.SaveChangesAsync();
return true;
}
public string GetReceiptPhysicalPath(Receipt receipt)
{
// StoragePath is like "receipts/filename.jpg"
return Path.Combine(_environment.WebRootPath, receipt.StoragePath.Replace("/", Path.DirectorySeparatorChar.ToString()));
}
public async Task<Receipt?> GetReceiptAsync(long receiptId)
{
return await _db.Receipts
.Include(r => r.Transaction)
.FirstOrDefaultAsync(r => r.Id == receiptId);
}
}
public class ReceiptUploadResult
{
public bool IsSuccess { get; init; }
public Receipt? Receipt { get; init; }
public string? ErrorMessage { get; init; }
public static ReceiptUploadResult Success(Receipt receipt) =>
new() { IsSuccess = true, Receipt = receipt };
public static ReceiptUploadResult Failure(string error) =>
new() { IsSuccess = false, ErrorMessage = error };
}
}

View File

@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
namespace MoneyMap.Services
{
// ===== Models =====
public class CategoryMapping
{
public int Id { get; set; }
public required string Category { get; set; }
public required string Pattern { get; set; }
public int Priority { get; set; } = 0; // Higher priority = checked first
}
// ===== Service Interface =====
public interface ITransactionCategorizer
{
Task<string> CategorizeAsync(string merchantName, decimal? amount = null);
Task<List<CategoryMapping>> GetAllMappingsAsync();
Task SeedDefaultMappingsAsync();
}
// ===== Service Implementation =====
public class TransactionCategorizer : ITransactionCategorizer
{
private readonly MoneyMapContext _db;
private const decimal GasStationThreshold = -20m;
public TransactionCategorizer(MoneyMapContext db)
{
_db = db;
}
public async Task<string> CategorizeAsync(string merchantName, decimal? amount = null)
{
if (string.IsNullOrWhiteSpace(merchantName))
return string.Empty;
var merchantUpper = merchantName.ToUpperInvariant();
// Get all mappings ordered by priority
var mappings = await _db.CategoryMappings
.OrderByDescending(m => m.Priority)
.ThenBy(m => m.Category)
.ToListAsync();
// Special case: Gas stations with small purchases
if (amount.HasValue && amount.Value > GasStationThreshold)
{
var gasMapping = mappings.FirstOrDefault(m =>
m.Category == "Gas & Auto" &&
merchantUpper.Contains(m.Pattern.ToUpperInvariant()));
if (gasMapping != null)
return "Convenience Store";
}
// Check each category's patterns
foreach (var mapping in mappings)
{
if (merchantUpper.Contains(mapping.Pattern.ToUpperInvariant()))
return mapping.Category;
}
return string.Empty; // No match - needs manual categorization
}
public async Task<List<CategoryMapping>> GetAllMappingsAsync()
{
return await _db.CategoryMappings
.OrderBy(m => m.Category)
.ThenByDescending(m => m.Priority)
.ToListAsync();
}
public async Task SeedDefaultMappingsAsync()
{
// Check if mappings already exist
if (await _db.CategoryMappings.AnyAsync())
return;
var defaultMappings = GetDefaultMappings();
_db.CategoryMappings.AddRange(defaultMappings);
await _db.SaveChangesAsync();
}
private static List<CategoryMapping> GetDefaultMappings()
{
var mappings = new List<CategoryMapping>();
// Online Shopping
AddMappings("Online shopping", mappings,
"AMAZON MKTPL", "AMAZON.COM", "BATHANDBODYWORKS", "BATH AND BODY",
"SEPHORA.COM", "ULTA.COM", "WWW.KOHLS.COM", "GAPOUTLET.COM",
"NIKE.COM", "HOMEDEPOT.COM", "TEMU.COM", "APPLE.COM",
"JOURNEYS.COM", "DECKERS*UGG", "YUNNANSOURCINGUS");
// Walmart
AddMappings("Walmart Online", mappings, "WALMART.COM");
AddMappings("Walmart Pickup/Grocery", mappings, "WALMART.C ", "DEBIT PURCHASE WALMART.C");
// Pizza
AddMappings("Pizza", mappings,
"CHICAGOS PIZZA", "PIZZA KING", "DOMINO", "BIG BOYZ",
"PAPA JOHN", "PIZZA 3.14", "HUNGRY HOWIES");
// Retail Stores
AddMappings("Brick/mortar store", mappings,
"DOLLAR-GENERAL", "DOLLAR GENERAL", "DOLLAR TREE", "GOODWILL STORE",
"WAL-MART", "WM SUPERCENTER", "KROGER", "TARGET", "LOWES",
"GILLMAN HOME CEN", "TRACTOR SUPPLY", "FIVE BELOW", "CLAIRE'S", "SAVE-A-LOT");
// Restaurants
AddMappings("Eat out / Restaurants", mappings,
"KUNKELS DRIVE IN", "MCDONALD", "STARBUCKS", "ASIAN DELIGHT",
"STACKS PANCAKE", "WENDY", "SUBWAY", "OLIVE GARDEN", "CRACKER BARREL",
"RED LOBSTER", "NO. 9 GRILL", "LEES FAMOUS", "OLE ROOSTE",
"EL CABALLO", "WAFFLE HOUSE", "GULF COAST BURG", "LAKEVIEW RESTAUR",
"ARBY", "BURGER KING", "DAIRY QUEEN", "TACO BELL", "DUNKIN", "CRUMBL");
// School
AddMappings("School", mappings,
"INTER-STATE STUD", "CREATIVE STEPS", "CPP*CONNERSVILLE");
// Health
AddMappings("Health", mappings,
"MEDICENTER", "REID HEALTH", "PHARMACY", "CVS", "WALGREENS",
"WHITEWATER EYE", "GIESTING FAMILY DENTIS");
// Gas & Auto (higher priority for special handling)
AddMappings("Gas & Auto", mappings, 100,
"SPEEDWAY", "MARATHON", "SHELL OIL", "BP#", "SUNOCO",
"WASH & LU", "WASH LUB", "CAR WASH", "MCDIVITT FAR",
"COUNTY TIRE", "BROOKVILLE SHELL", "BUC-EE'S", "CIRCLE K", "MAIN STREET QUIC");
// Utilities
AddMappings("Utilities/Services", mappings,
"SMARTSTOP", "VZWRLSS", "VERIZON", "COMCAST", "XFINIT",
"US MOBILE", "WHITEWATER VALLE", "RUMPKE");
// Entertainment
AddMappings("Entertainment", mappings,
"SHOWTIME CINEMA", "SHOWPLACE CINEMA", "RICHMOND CIV", "KINDLE",
"GOOGLE *Google S", "NINTENDO", "HLU*HULU", "HULU", "NETFLIX",
"SPOTIFY", "STEAMGAMES", "WL *STEAM PURCHASE", "ETSY", "GEEK-HUB");
// Banking (high priority to catch these first)
AddMappings("Banking", mappings, 200,
"ATM WITHDRAWAL", "ATM FEE", "MOBILE BANKING ADVANCE",
"MOBILE BANKING PAYMENT", "MOBILE BANKING TRANSFER", "OVERDRAFT",
"MONTHLY MAINTENANCE FEE", "OD PROTECTION", "RESERVE LINE",
"FRGN TRANS FEE", "START SCHEDULED TRANSFER");
// Mortgage
AddMappings("Mortgage", mappings, "WAYNE BANK");
// Car Payment
AddMappings("Car Payment", mappings, "UNION SAVINGS AN");
// Convenience Store
AddMappings("Convenience Store", mappings,
"PAVEYS COUNTRY", "CAMBRIDGE CITY M", "WHITEWATER QUICK");
// Income (high priority)
AddMappings("Income", mappings, 200,
"MOBILE CHECK DEPOSIT", "ELECTRONIC DEPOSIT", "IRS TREAS",
"RPA PA", "REWARDS REDEEMED");
// Taxes
AddMappings("Taxes", mappings, "MYERS INCOME TAX");
// Insurance
AddMappings("Insurance", mappings,
"BOSTON MUTUAL", "IND FARMERS INS", "GERBER LIFE INS");
// Credit Card Payment
AddMappings("Credit Card Payment", mappings,
"PAYMENT TO CREDIT CARD", "CAPITAL ONE", "MOBILE PAYMENT THANK YOU");
// Ice Cream
AddMappings("Ice Cream / Treats", mappings, "DAIRY TWIST", "URANUS FUDGE");
// Government
AddMappings("Government/DMV", mappings, "IN BMV", "KY-IN RIVERLINK");
// Home Services
AddMappings("Home Services", mappings, "DUNGAN PLUMBING");
// Special Occasions
AddMappings("Special Occasions", mappings,
"CLARKS FLOWER", "THE CAKE BAK", "DOUGHERTY OR");
// Home Improvement
AddMappings("Home Improvement", mappings,
"SHERWIN-WILLIAMS", "PAINTERS SUPPLY", "MENARDS", "LOWES #00907",
"123FILTER", "O-RING STORE");
// Software/Subscriptions
AddMappings("Software/Subscriptions", mappings,
"GOOGLE *ChatGPT", "CLAUDE.AI", "OPENAI", "NAME-CHEAP",
"AMAZON PRIME*", "BITWARDEN", "GOOGLE *Shopping List");
return mappings;
}
private static void AddMappings(string category, List<CategoryMapping> mappings, params string[] patterns)
{
AddMappings(category, mappings, 0, patterns);
}
private static void AddMappings(string category, List<CategoryMapping> mappings, int priority, params string[] patterns)
{
foreach (var pattern in patterns)
{
mappings.Add(new CategoryMapping
{
Category = category,
Pattern = pattern,
Priority = priority
});
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"DetailedErrors": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

17
MoneyMap/appsettings.json Normal file
View File

@@ -0,0 +1,17 @@
{
"ConnectionStrings": {
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Kestrel": {
"Endpoints": {
"Http": { "Url": "http://0.0.0.0:5001" }
}
},
"AllowedHosts": "*"
}

14
MoneyMap/libman.json Normal file
View File

@@ -0,0 +1,14 @@
{
"version": "3.0",
"defaultProvider": "cdnjs",
"libraries": [
{
"library": "bootstrap@5.3.8",
"destination": "wwwroot/lib/bootstrap/"
},
{
"library": "jquery@3.7.1",
"destination": "wwwroot/lib/jquery/"
}
]
}

View File

@@ -0,0 +1,22 @@
html {
font-size: 14px;
}
@media (min-width: 768px) {
html {
font-size: 16px;
}
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
html {
position: relative;
min-height: 100%;
}
body {
margin-bottom: 60px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,4 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

BIN
publish/Azure.Core.dll Normal file

Binary file not shown.

BIN
publish/Azure.Identity.dll Normal file

Binary file not shown.

BIN
publish/CsvHelper.dll Normal file

Binary file not shown.

Binary file not shown.

BIN
publish/Magick.NET.Core.dll Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1425
publish/MoneyMap.deps.json Normal file

File diff suppressed because it is too large Load Diff

BIN
publish/MoneyMap.dll Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
{
"runtimeOptions": {
"tfm": "net8.0",
"frameworks": [
{
"name": "Microsoft.NETCore.App",
"version": "8.0.0"
},
{
"name": "Microsoft.AspNetCore.App",
"version": "8.0.0"
}
],
"configProperties": {
"System.GC.Server": true,
"System.Reflection.Metadata.MetadataUpdater.IsSupported": false,
"System.Reflection.NullabilityInfoContext.IsSupported": true,
"System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
}
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More