feat: add recurring places and transaction pagination
Build MoneyMap image / build-and-push (push) Failing after 4s
Build MoneyMap image / build-and-push (push) Failing after 4s
This commit is contained in:
@@ -16,7 +16,11 @@
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet new:*)",
|
||||
"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": [],
|
||||
"ask": []
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
- "MoneyMap/**"
|
||||
- "MoneyMap.Core/**"
|
||||
- "MoneyMap.Mcp/**"
|
||||
- "MoneyMap/Dockerfile"
|
||||
- "Dockerfile"
|
||||
- ".gitea/workflows/build-moneymap.yml"
|
||||
workflow_dispatch: {}
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- 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
|
||||
run: |
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace MoneyMap.Data
|
||||
public DbSet<CategoryMapping> CategoryMappings => Set<CategoryMapping>();
|
||||
public DbSet<Merchant> Merchants => Set<Merchant>();
|
||||
public DbSet<Budget> Budgets => Set<Budget>();
|
||||
public DbSet<RecurringPlace> RecurringPlaces => Set<RecurringPlace>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -222,6 +224,53 @@ namespace MoneyMap.Data
|
||||
.HasFilter("[IsActive] = 1")
|
||||
.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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class Card
|
||||
public string? Nickname { get; set; } // Optional friendly name
|
||||
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
|
||||
|
||||
[NotMapped]
|
||||
public string DisplayLabel => string.IsNullOrEmpty(Nickname)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public class Merchant
|
||||
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
|
||||
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -31,8 +31,11 @@ public class TransactionsController : ControllerBase
|
||||
[FromQuery] int? cardId = null,
|
||||
[FromQuery] string? type = 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
|
||||
.Include(t => t.Merchant)
|
||||
.Include(t => t.Card)
|
||||
@@ -75,9 +78,12 @@ public class TransactionsController : ControllerBase
|
||||
if (uncategorizedOnly == true)
|
||||
q = q.Where(t => t.Category == null || t.Category == "");
|
||||
|
||||
var totalCount = await q.CountAsync();
|
||||
|
||||
var results = await q
|
||||
.OrderByDescending(t => t.Date).ThenByDescending(t => t.Id)
|
||||
.Take(limit ?? 50)
|
||||
.Skip(pageOffset)
|
||||
.Take(pageLimit)
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
@@ -94,7 +100,17 @@ public class TransactionsController : ControllerBase
|
||||
})
|
||||
.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}")]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user