Add transfer support between accounts
- Added TransferToAccountId to Transaction model for linking transfers - Created CreateTransfer page for recording account-to-account transfers - Migration adds the new foreign key relationship - Transfers are created as paired transactions (debit and credit) This enables tracking of money movement between bank accounts while maintaining proper accounting with matching transaction pairs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
539
MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs
generated
Normal file
539
MoneyMap/Migrations/20251011014001_AddTransferSupport.Designer.cs
generated
Normal file
@@ -0,0 +1,539 @@
|
||||
// <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("20251011014001_AddTransferSupport")]
|
||||
partial class AddTransferSupport
|
||||
{
|
||||
/// <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.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Institution")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Institution", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("Issuer", "Last4", "Owner");
|
||||
|
||||
b.ToTable("Cards");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Property<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<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("CardId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Last4")
|
||||
.HasMaxLength(4)
|
||||
.HasColumnType("nvarchar(4)");
|
||||
|
||||
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.Property<int?>("TransferToAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("Amount");
|
||||
|
||||
b.HasIndex("CardId");
|
||||
|
||||
b.HasIndex("Category");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("TransferToAccountId");
|
||||
|
||||
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
|
||||
.IsUnique()
|
||||
.HasFilter("[CardId] IS NOT NULL");
|
||||
|
||||
b.ToTable("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<int?>("DestinationAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<long?>("OriginalTransactionId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("SourceAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.HasIndex("DestinationAccountId");
|
||||
|
||||
b.HasIndex("OriginalTransactionId");
|
||||
|
||||
b.HasIndex("SourceAccountId");
|
||||
|
||||
b.ToTable("Transfers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Services.CategoryMapping", b =>
|
||||
{
|
||||
b.Property<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.Card", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Cards")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
|
||||
.WithMany("Receipts")
|
||||
.HasForeignKey("TransactionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Transaction");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("LineItems")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
|
||||
.WithMany("ParseLogs")
|
||||
.HasForeignKey("ReceiptId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Receipt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "Account")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Card", "Card")
|
||||
.WithMany("Transactions")
|
||||
.HasForeignKey("CardId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransferToAccountId");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Card");
|
||||
|
||||
b.Navigation("TransferToAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
|
||||
{
|
||||
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
|
||||
.WithMany("DestinationTransfers")
|
||||
.HasForeignKey("DestinationAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
|
||||
.WithMany()
|
||||
.HasForeignKey("OriginalTransactionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
|
||||
.WithMany("SourceTransfers")
|
||||
.HasForeignKey("SourceAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("DestinationAccount");
|
||||
|
||||
b.Navigation("OriginalTransaction");
|
||||
|
||||
b.Navigation("SourceAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Account", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
b.Navigation("DestinationTransfers");
|
||||
|
||||
b.Navigation("SourceTransfers");
|
||||
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Card", b =>
|
||||
{
|
||||
b.Navigation("Transactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
|
||||
{
|
||||
b.Navigation("LineItems");
|
||||
|
||||
b.Navigation("ParseLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
|
||||
{
|
||||
b.Navigation("Receipts");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
48
MoneyMap/Migrations/20251011014001_AddTransferSupport.cs
Normal file
48
MoneyMap/Migrations/20251011014001_AddTransferSupport.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MoneyMap.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTransferSupport : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TransferToAccountId",
|
||||
table: "Transactions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Transactions_TransferToAccountId",
|
||||
table: "Transactions",
|
||||
column: "TransferToAccountId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Transactions_Accounts_TransferToAccountId",
|
||||
table: "Transactions",
|
||||
column: "TransferToAccountId",
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Transactions_Accounts_TransferToAccountId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Transactions_TransferToAccountId",
|
||||
table: "Transactions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TransferToAccountId",
|
||||
table: "Transactions");
|
||||
}
|
||||
}
|
||||
}
|
||||
128
MoneyMap/Pages/CreateTransfer.cshtml
Normal file
128
MoneyMap/Pages/CreateTransfer.cshtml
Normal file
@@ -0,0 +1,128 @@
|
||||
@page
|
||||
@model MoneyMap.Pages.CreateTransferModel
|
||||
@{
|
||||
ViewData["Title"] = "Create Transfer";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>Create Transfer</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 justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<strong>Transfer Details</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>Note:</strong> This will create two matching transactions - a debit from the source account and a credit to the destination account.
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="FromAccountId" class="form-label fw-bold">From Account</label>
|
||||
<select asp-for="FromAccountId" class="form-select" required>
|
||||
<option value="">-- Select source account --</option>
|
||||
@foreach (var account in Model.Accounts)
|
||||
{
|
||||
<option value="@account.Id">@account.DisplayLabel</option>
|
||||
}
|
||||
</select>
|
||||
<span asp-validation-for="FromAccountId" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ToAccountId" class="form-label fw-bold">To Account</label>
|
||||
<select asp-for="ToAccountId" class="form-select" required>
|
||||
<option value="">-- Select destination account --</option>
|
||||
@foreach (var account in Model.Accounts)
|
||||
{
|
||||
<option value="@account.Id">@account.DisplayLabel</option>
|
||||
}
|
||||
</select>
|
||||
<span asp-validation-for="ToAccountId" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Date" class="form-label fw-bold">Date</label>
|
||||
<input asp-for="Date" type="date" class="form-control" required />
|
||||
<span asp-validation-for="Date" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Amount" class="form-label fw-bold">Amount</label>
|
||||
<input asp-for="Amount" type="number" step="0.01" min="0.01" class="form-control" placeholder="0.00" required />
|
||||
<span asp-validation-for="Amount" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Memo" class="form-label fw-bold">Memo (optional)</label>
|
||||
<input asp-for="Memo" type="text" class="form-control" placeholder="e.g., 'Monthly savings transfer'" />
|
||||
<div class="form-text">Optional note to help you identify this transfer</div>
|
||||
<span asp-validation-for="Memo" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Category" class="form-label fw-bold">Category (optional)</label>
|
||||
<input asp-for="Category" type="text" class="form-control" placeholder="Transfer" list="categoryList" />
|
||||
<div class="form-text">Defaults to "Transfer" if left blank</div>
|
||||
<span asp-validation-for="Category" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<!-- Category autocomplete -->
|
||||
<datalist id="categoryList">
|
||||
<option value="Transfer">Transfer</option>
|
||||
@foreach (var cat in Model.AvailableCategories)
|
||||
{
|
||||
<option value="@cat">@cat</option>
|
||||
}
|
||||
</datalist>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-success btn-lg">Create Transfer</button>
|
||||
<a asp-page="/Transactions" class="btn btn-secondary btn-lg">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header">
|
||||
<strong>How Transfers Work</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="mb-0">
|
||||
<li>Creates two linked transactions to maintain balance accuracy</li>
|
||||
<li>Source account gets a <strong class="text-danger">debit</strong> (money out)</li>
|
||||
<li>Destination account gets a <strong class="text-success">credit</strong> (money in)</li>
|
||||
<li>Both transactions are linked and can be identified as transfers</li>
|
||||
<li>Transfers can be filtered out when viewing spending reports</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
147
MoneyMap/Pages/CreateTransfer.cshtml.cs
Normal file
147
MoneyMap/Pages/CreateTransfer.cshtml.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
{
|
||||
public class CreateTransferModel : PageModel
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
|
||||
public CreateTransferModel(MoneyMapContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[BindProperty]
|
||||
[Required(ErrorMessage = "Please select a source account")]
|
||||
public int FromAccountId { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
[Required(ErrorMessage = "Please select a destination account")]
|
||||
public int ToAccountId { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
[Required]
|
||||
public DateTime Date { get; set; } = DateTime.Today;
|
||||
|
||||
[BindProperty]
|
||||
[Required]
|
||||
[Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than 0")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[BindProperty]
|
||||
[MaxLength(500)]
|
||||
public string Memo { get; set; } = string.Empty;
|
||||
|
||||
[BindProperty]
|
||||
[MaxLength(100)]
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
public List<Account> Accounts { get; set; } = new();
|
||||
public List<string> AvailableCategories { get; set; } = new();
|
||||
|
||||
[TempData]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
// Custom validation
|
||||
if (FromAccountId == ToAccountId)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, "Source and destination accounts must be different");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Verify both accounts exist
|
||||
var fromAccount = await _db.Accounts.FindAsync(FromAccountId);
|
||||
var toAccount = await _db.Accounts.FindAsync(ToAccountId);
|
||||
|
||||
if (fromAccount == null || toAccount == null)
|
||||
{
|
||||
ErrorMessage = "One or both accounts not found";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Use "Transfer" as default category if not specified
|
||||
var transferCategory = string.IsNullOrWhiteSpace(Category) ? "Transfer" : Category.Trim();
|
||||
var transferMemo = string.IsNullOrWhiteSpace(Memo)
|
||||
? $"Transfer to {toAccount.DisplayLabel}"
|
||||
: Memo.Trim();
|
||||
|
||||
// Create the debit transaction (source account - money out)
|
||||
var debitTransaction = new Transaction
|
||||
{
|
||||
Date = Date,
|
||||
Name = $"Transfer to {toAccount.DisplayLabel}",
|
||||
Memo = transferMemo,
|
||||
Amount = -Amount, // Negative for debit
|
||||
Category = transferCategory,
|
||||
AccountId = FromAccountId,
|
||||
TransferToAccountId = ToAccountId,
|
||||
CardId = null // Transfers don't use cards
|
||||
};
|
||||
|
||||
// Create the credit transaction (destination account - money in)
|
||||
var creditTransaction = new Transaction
|
||||
{
|
||||
Date = Date,
|
||||
Name = $"Transfer from {fromAccount.DisplayLabel}",
|
||||
Memo = transferMemo,
|
||||
Amount = Amount, // Positive for credit
|
||||
Category = transferCategory,
|
||||
AccountId = ToAccountId,
|
||||
TransferToAccountId = FromAccountId, // Links back to source
|
||||
CardId = null
|
||||
};
|
||||
|
||||
_db.Transactions.Add(debitTransaction);
|
||||
_db.Transactions.Add(creditTransaction);
|
||||
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync();
|
||||
SuccessMessage = $"Transfer of {Amount:C} from {fromAccount.DisplayLabel} to {toAccount.DisplayLabel} created successfully!";
|
||||
return RedirectToPage("/Transactions");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Failed to create transfer: {ex.Message}";
|
||||
await LoadDataAsync();
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
// Load accounts and order in memory by computed property
|
||||
var accounts = await _db.Accounts.ToListAsync();
|
||||
Accounts = accounts.OrderBy(a => a.DisplayLabel).ToList();
|
||||
|
||||
AvailableCategories = await _db.Transactions
|
||||
.Select(t => t.Category ?? "")
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c))
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user