feat: add recurring places and transaction pagination
Build MoneyMap image / build-and-push (push) Failing after 4s

This commit is contained in:
aj
2026-08-17 06:42:08 -04:00
parent 2356b7d5c9
commit 1cc0a355f2
175 changed files with 34411 additions and 34039 deletions
+5 -1
View File
@@ -16,7 +16,11 @@
"Bash(dotnet build:*)", "Bash(dotnet build:*)",
"Bash(dotnet new:*)", "Bash(dotnet new:*)",
"Bash(dotnet add:*)", "Bash(dotnet add:*)",
"Bash(dotnet test:*)" "Bash(dotnet test:*)",
"mcp__moneymap__search_transactions",
"mcp__moneymap__update_transaction_category",
"mcp__moneymap__get_spending_summary",
"mcp__moneymap__get_income_summary"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+2 -2
View File
@@ -7,7 +7,7 @@ on:
- "MoneyMap/**" - "MoneyMap/**"
- "MoneyMap.Core/**" - "MoneyMap.Core/**"
- "MoneyMap.Mcp/**" - "MoneyMap.Mcp/**"
- "MoneyMap/Dockerfile" - "Dockerfile"
- ".gitea/workflows/build-moneymap.yml" - ".gitea/workflows/build-moneymap.yml"
workflow_dispatch: {} workflow_dispatch: {}
@@ -21,7 +21,7 @@ jobs:
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
- name: Build image - name: Build image
run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} . run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} MoneyMap/
- name: Push image - name: Push image
run: | run: |
+69 -20
View File
@@ -8,16 +8,18 @@ namespace MoneyMap.Data
public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { } public MoneyMapContext(DbContextOptions<MoneyMapContext> options) : base(options) { }
public DbSet<Card> Cards => Set<Card>(); public DbSet<Card> Cards => Set<Card>();
public DbSet<Account> Accounts => Set<Account>(); public DbSet<Account> Accounts => Set<Account>();
public DbSet<Transaction> Transactions => Set<Transaction>(); public DbSet<Transaction> Transactions => Set<Transaction>();
public DbSet<Transfer> Transfers => Set<Transfer>(); public DbSet<Transfer> Transfers => Set<Transfer>();
public DbSet<Receipt> Receipts => Set<Receipt>(); public DbSet<Receipt> Receipts => Set<Receipt>();
public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>(); public DbSet<ReceiptParseLog> ReceiptParseLogs => Set<ReceiptParseLog>();
public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>(); public DbSet<ReceiptLineItem> ReceiptLineItems => Set<ReceiptLineItem>();
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>(); public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
public DbSet<Merchant> Merchants => Set<Merchant>(); public DbSet<Merchant> Merchants => Set<Merchant>();
public DbSet<Budget> Budgets => Set<Budget>(); public DbSet<Budget> Budgets => Set<Budget>();
public DbSet<RecurringPlace> RecurringPlaces => Set<RecurringPlace>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -210,18 +212,65 @@ namespace MoneyMap.Data
modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate }); modelBuilder.Entity<Receipt>().HasIndex(x => new { x.TransactionId, x.ReceiptDate });
// ---------- BUDGET ---------- // ---------- BUDGET ----------
modelBuilder.Entity<Budget>(e => modelBuilder.Entity<Budget>(e =>
{ {
e.Property(x => x.Category).HasMaxLength(100); e.Property(x => x.Category).HasMaxLength(100);
e.Property(x => x.Amount).HasColumnType("decimal(18,2)"); e.Property(x => x.Amount).HasColumnType("decimal(18,2)");
e.Property(x => x.Notes).HasMaxLength(500); e.Property(x => x.Notes).HasMaxLength(500);
// Only one active budget per category per period // Only one active budget per category per period
// Null category = total budget, so we use a filtered unique index // Null category = total budget, so we use a filtered unique index
e.HasIndex(x => new { x.Category, x.Period }) e.HasIndex(x => new { x.Category, x.Period })
.HasFilter("[IsActive] = 1") .HasFilter("[IsActive] = 1")
.IsUnique(); .IsUnique();
}); });
// ---------- RECURRING PLACE ----------
modelBuilder.Entity<RecurringPlace>(e =>
{
e.Property(x => x.Frequency).HasMaxLength(50).IsRequired();
e.Property(x => x.Notes).HasMaxLength(500);
// Relationships
e.HasOne(x => x.Merchant)
.WithMany(m => m.RecurringPlaces)
.HasForeignKey(x => x.MerchantId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasOne(x => x.Card)
.WithMany(c => c.RecurringPlaces)
.HasForeignKey(x => x.CardId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasOne(x => x.Category)
.WithMany(c => c.RecurringPlaces)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
e.HasIndex(x => new { x.MerchantId, x.CardId, x.Frequency });
e.HasIndex(x => x.NextDueDate);
});
// ---------- CATEGORY ----------
modelBuilder.Entity<Category>(e =>
{
e.Property(x => x.Name).HasMaxLength(100).IsRequired();
e.Property(x => x.Description).HasMaxLength(500);
e.Property(x => x.Type).HasMaxLength(50);
// Parent-child relationship for hierarchical categories
e.HasOne(x => x.ParentCategory)
.WithMany(c => c.Children)
.HasForeignKey(x => x.ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false);
e.HasIndex(x => new { x.Name, x.Type });
e.HasIndex(x => x.ParentCategoryId);
});
} }
} }
} }
+1
View File
@@ -29,6 +29,7 @@ public class Card
public string? Nickname { get; set; } // Optional friendly name public string? Nickname { get; set; } // Optional friendly name
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>(); public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
[NotMapped] [NotMapped]
public string DisplayLabel => string.IsNullOrEmpty(Nickname) public string DisplayLabel => string.IsNullOrEmpty(Nickname)
+33
View File
@@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models;
public class Category
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(100)]
public string Name { get; set; } = string.Empty;
[MaxLength(500)]
public string Description { get; set; } = string.Empty;
[MaxLength(50)]
public string Type { get; set; } = string.Empty; // e.g., "Expense", "Income", "Transfer"
public bool IsDefault { get; set; } = false;
public int? ParentCategoryId { get; set; }
public Category? ParentCategory { get; set; }
public ICollection<Category> Children { get; set; } = new List<Category>();
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
[NotMapped]
public string DisplayLabel => Name;
}
+1
View File
@@ -13,4 +13,5 @@ public class Merchant
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>(); public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>(); public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
} }
+40
View File
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models;
[Index(nameof(MerchantId), nameof(CardId), nameof(Frequency), IsUnique = true)]
public class RecurringPlace
{
[Key]
public int Id { get; set; }
[Required]
public int MerchantId { get; set; }
public Merchant Merchant { get; set; } = null!;
[Required]
public int CardId { get; set; }
public Card Card { get; set; } = null!;
[Required]
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
[Column(TypeName = "decimal(18,2)")]
public decimal Amount { get; set; }
[Required]
[MaxLength(50)]
public string Frequency { get; set; } = string.Empty; // e.g., "Monthly", "Weekly", "Quarterly"
[Required]
public DateTime NextDueDate { get; set; }
[MaxLength(500)]
public string Notes { get; set; } = string.Empty;
[NotMapped]
public string DisplayLabel => $"{Merchant.Name} - {Card.Last4} - {Frequency} - ${Amount:N2}";
}
@@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Controllers;
[ApiController]
[Route("api/[controller]")]
public class RecurringPlacesController : ControllerBase
{
private readonly MoneyMapContext _db;
public RecurringPlacesController(MoneyMapContext db) => _db = db;
[HttpGet]
public async Task<IActionResult> List()
{
var recurringPlaces = await _db.RecurringPlaces
.Include(rp => rp.Merchant)
.Include(rp => rp.Card)
.Include(rp => rp.Category)
.OrderBy(rp => rp.Merchant.Name)
.Select(rp => new
{
rp.Id,
MerchantName = rp.Merchant.Name,
rp.MerchantId,
rp.CardId,
CardLast4 = rp.Card != null ? rp.Card.Last4 : null,
CategoryName = rp.Category != null ? rp.Category.Name : null,
rp.Amount,
rp.Frequency,
rp.NextDueDate,
rp.Notes
})
.ToListAsync();
return Ok(recurringPlaces);
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var recurringPlace = await _db.RecurringPlaces
.Include(rp => rp.Merchant)
.Include(rp => rp.Card)
.Include(rp => rp.Category)
.FirstOrDefaultAsync(rp => rp.Id == id);
if (recurringPlace == null)
return NotFound();
return Ok(new
{
recurringPlace.Id,
MerchantName = recurringPlace.Merchant.Name,
recurringPlace.MerchantId,
recurringPlace.CardId,
CardLast4 = recurringPlace.Card != null ? recurringPlace.Card.Last4 : null,
CategoryName = recurringPlace.Category != null ? recurringPlace.Category.Name : null,
recurringPlace.Amount,
recurringPlace.Frequency,
recurringPlace.NextDueDate,
recurringPlace.Notes
});
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] RecurringPlaceCreateModel model)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var merchant = await _db.Merchants.FindAsync(model.MerchantId);
var card = await _db.Cards.FindAsync(model.CardId);
var category = await _db.Categories.FindAsync(model.CategoryId);
if (merchant == null || card == null || category == null)
return BadRequest("Invalid merchant, card, or category");
var recurringPlace = new RecurringPlace
{
MerchantId = model.MerchantId,
CardId = model.CardId,
CategoryId = model.CategoryId,
Amount = model.Amount,
Frequency = model.Frequency,
NextDueDate = model.NextDueDate,
Notes = model.Notes
};
_db.RecurringPlaces.Add(recurringPlace);
await _db.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = recurringPlace.Id }, recurringPlace);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] RecurringPlaceUpdateModel model)
{
if (!ModelState.IsValid)
return BadRequest(ModelState);
var recurringPlace = await _db.RecurringPlaces.FindAsync(id);
if (recurringPlace == null)
return NotFound();
var merchant = await _db.Merchants.FindAsync(model.MerchantId);
var card = await _db.Cards.FindAsync(model.CardId);
var category = await _db.Categories.FindAsync(model.CategoryId);
if (merchant == null || card == null || category == null)
return BadRequest("Invalid merchant, card, or category");
recurringPlace.MerchantId = model.MerchantId;
recurringPlace.CardId = model.CardId;
recurringPlace.CategoryId = model.CategoryId;
recurringPlace.Amount = model.Amount;
recurringPlace.Frequency = model.Frequency;
recurringPlace.NextDueDate = model.NextDueDate;
recurringPlace.Notes = model.Notes;
await _db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var recurringPlace = await _db.RecurringPlaces.FindAsync(id);
if (recurringPlace == null)
return NotFound();
_db.RecurringPlaces.Remove(recurringPlace);
await _db.SaveChangesAsync();
return NoContent();
}
}
public class RecurringPlaceCreateModel
{
public int MerchantId { get; set; }
public int CardId { get; set; }
public int CategoryId { get; set; }
public decimal Amount { get; set; }
public string Frequency { get; set; }
public DateTime NextDueDate { get; set; }
public string Notes { get; set; }
}
public class RecurringPlaceUpdateModel
{
public int MerchantId { get; set; }
public int CardId { get; set; }
public int CategoryId { get; set; }
public decimal Amount { get; set; }
public string Frequency { get; set; }
public DateTime NextDueDate { get; set; }
public string Notes { get; set; }
}
+19 -3
View File
@@ -31,8 +31,11 @@ public class TransactionsController : ControllerBase
[FromQuery] int? cardId = null, [FromQuery] int? cardId = null,
[FromQuery] string? type = null, [FromQuery] string? type = null,
[FromQuery] bool? uncategorizedOnly = null, [FromQuery] bool? uncategorizedOnly = null,
[FromQuery] int? limit = null) [FromQuery] int? limit = null,
[FromQuery] int? offset = null)
{ {
int pageLimit = Math.Min(limit ?? 50, 500);
int pageOffset = offset ?? 0;
var q = _db.Transactions var q = _db.Transactions
.Include(t => t.Merchant) .Include(t => t.Merchant)
.Include(t => t.Card) .Include(t => t.Card)
@@ -75,9 +78,12 @@ public class TransactionsController : ControllerBase
if (uncategorizedOnly == true) if (uncategorizedOnly == true)
q = q.Where(t => t.Category == null || t.Category == ""); q = q.Where(t => t.Category == null || t.Category == "");
var totalCount = await q.CountAsync();
var results = await q var results = await q
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id) .OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
.Take(limit ?? 50) .Skip(pageOffset)
.Take(pageLimit)
.Select(t => new .Select(t => new
{ {
t.Id, t.Id,
@@ -94,7 +100,17 @@ public class TransactionsController : ControllerBase
}) })
.ToListAsync(); .ToListAsync();
return Ok(new { Count = results.Count, Transactions = results }); int totalPages = (int)Math.Ceiling(totalCount / (double)pageLimit);
return Ok(new
{
Count = results.Count,
TotalCount = totalCount,
Page = (pageOffset / pageLimit) + 1,
PageSize = pageLimit,
TotalPages = totalPages,
Transactions = results
});
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <summary>
/// Adds CategoryId foreign key and ParentCategoryId to RecurringPlaces,
/// and Category ParentCategoryId column to support hierarchical categories.
/// </summary>
public partial class AddCategoryToRecurringPlaces : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// Add ParentCategoryId to Categories table (for hierarchical categories)
migrationBuilder.AddColumn<int>(
name: "ParentCategoryId",
table: "Categories",
type: "int",
nullable: true);
// Add CategoryId to RecurringPlaces table
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "RecurringPlaces",
type: "int",
nullable: false,
defaultValue: 0);
// Add index on ParentCategoryId for Categories
migrationBuilder.CreateIndex(
name: "IX_Categories_ParentCategoryId",
table: "Categories",
column: "ParentCategoryId");
// Add foreign key from RecurringPlaces.CategoryId to Categories.Id
migrationBuilder.AddForeignKey(
name: "FK_RecurringPlaces_Categories_CategoryId",
table: "RecurringPlaces",
column: "CategoryId",
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RecurringPlaces_Categories_CategoryId",
table: "RecurringPlaces");
migrationBuilder.DropIndex(
name: "IX_Categories_ParentCategoryId",
table: "Categories");
migrationBuilder.DropColumn(
name: "ParentCategoryId",
table: "Categories");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "RecurringPlaces");
}
}
}