From 775d09e9c205e667745882dcfd94cad54ac538e2 Mon Sep 17 00:00:00 2001 From: santidev21 Date: Thu, 1 Oct 2026 14:06:13 -0500 Subject: [PATCH 1/2] feat(budgets): make a month inherit the previous budget and backfill legacy allocations A budget set before recurring budgets existed stayed as a single month's snapshot, so the next month opened with nothing: a full September left October with zero allocations. - Migration 20261001185217_BackfillBudgetDefaults (SQL in BudgetDefaultsBackfill) turns the latest allocation of each category into a budget_defaults row effective from that month, so the following month inherits it. It is idempotent and never invents a budget for an earlier month. - IBudgetService.PromoteMonthToDefaultsAsync and the budget screen's "Aplicar todos los meses" button turn the current month's overrides into the recurring default, keeping an existing default's effective_from and leaving an override whose default starts later. - Replace the "a later month does not inherit" test with the inheritance contract (BudgetInheritanceTests); the per-month snapshot guarantee stays. - Correct TECHNICAL-DESIGN.md, gotchas.md and HANDOFF.md, which still described the retired "copy last month" button. --- docs/HANDOFF.md | 12 +- docs/TECHNICAL-DESIGN.md | 33 +- docs/specs/gotchas.md | 6 + .../Budgets/BudgetService.cs | 65 ++ .../Localization/MessageKeys.cs | 4 + .../Resources/Messages.resx | 6 + .../Persistence/BudgetDefaultsBackfill.cs | 52 ++ ...1185217_BackfillBudgetDefaults.Designer.cs | 843 ++++++++++++++++++ .../20261001185217_BackfillBudgetDefaults.cs | 26 + .../CategoriesConversation.Budgets.cs | 38 +- .../Budgets/BudgetServiceTests.cs | 82 ++ .../Reporting/ReportServiceTests.cs | 23 + .../BudgetInheritanceTests.cs | 134 +++ .../HistoricalBudgetTests.cs | 5 +- .../CategoriesConversationBudgetTests.cs | 57 ++ 15 files changed, 1366 insertions(+), 20 deletions(-) create mode 100644 src/MyBudget.Infrastructure/Persistence/BudgetDefaultsBackfill.cs create mode 100644 src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.Designer.cs create mode 100644 src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.cs create mode 100644 tests/MyBudget.Infrastructure.Tests/BudgetInheritanceTests.cs diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index cffd1f6..251c1eb 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -27,10 +27,10 @@ day as fallback; verified nightly backups; reproducible deploy. Settings erases everything and switches the daily reminder; a budget set once recurs every month with per-month overrides; the summary lists remaining budget and drills into each category's movements. The category chart measures each bar as its share of the month -and draws the category name; the breakdown has a way back. **835 tests green**, build with zero +and draws the category name; the breakdown has a way back. **848 tests green**, build with zero warnings, `dotnet format` clean. -## Done in this round (all verified, 835 tests green) +## Done in this round (all verified, 848 tests green) **Scheduled-notification foundation** @@ -64,6 +64,14 @@ warnings, `dotnet format` clean. - The "copy previous month" feature was retired everywhere: domain `CopyAllocationsFrom`, `IBudgetService.CopyPreviousMonthAsync`, `ClosingCopyHandler`, `ClosingCopyCallback`, the budget-screen button and the closing-message button, and their message keys. +- Migration `20261001185217_BackfillBudgetDefaults` (SQL in `BudgetDefaultsBackfill`) repairs the + users who predate recurring budgets: it turns the latest allocation of each category into a + `budget_defaults` row, so a later month inherits it without a copy. It is idempotent and never + invents a budget for a month before the allocation. +- `IBudgetService.PromoteMonthToDefaultsAsync` and the budget screen's "🔁 Aplicar todos los + meses" button turn the current month's overrides into the recurring default (keeping an + existing default's `effective_from`). Covered by `BudgetServiceTests`, `BudgetInheritanceTests` + and `CategoriesConversationBudgetTests`. **Summary and per-category breakdown** diff --git a/docs/TECHNICAL-DESIGN.md b/docs/TECHNICAL-DESIGN.md index 2ccd56c..1dddc2d 100644 --- a/docs/TECHNICAL-DESIGN.md +++ b/docs/TECHNICAL-DESIGN.md @@ -166,8 +166,11 @@ monthly_budgets(user, 2026-10) ──< monthly_budget_categories(Market, 1_500_0 - **The monthly total is derived** (`SUM`), never stored, never cached. At personal scale (tens of rows per month) this is a single index scan. A denormalised total would be a liability with no benefit. -- **Rollover is explicit.** `GetOrCreate` creates an empty month. The bot offers "copy last - month's budget" as a deliberate action; nothing is copied silently. +- **Rollover is a recurring default.** A budget set once becomes a `budget_defaults` row that + applies to every month from the one it was created in; a month with its own row overrides it. + A month therefore inherits the previous one without a copy, and the budget screen offers + "aplicar todos los meses" to turn the current month's overrides into the default. Nothing is + copied silently into a month that never had an allocation. - **Past months are immutable in the product**: budgets can be edited for the current and future months only. The database does not enforce that (it would block data repair), so the application layer does. @@ -457,9 +460,9 @@ test proves the ambient culture is ignored. 6. **Expense detail** — amount, description, category, date; edit, delete (with confirmation), back. 7. **Categories** — create, rename, icon, aliases with conflict handling, activate/ - deactivate, set this month's allocation, copy last month. Deletion is not offered; - deactivation is, with an explanation. Only the current month is editable, and the bot says - so rather than silently refusing; copying is always an explicit action. + deactivate, set this month's allocation, make it recur ("aplicar todos los meses"). Deletion + is not offered; deactivation is, with an explanation. Only the current month is editable, and + the bot says so rather than silently refusing. 8. **Statistics** — by category with share, daily spending, largest expenses, average daily, period comparison that states when a period is incomplete. 9. **Configuration** — language, time zone, currency (read-only until multi-currency), @@ -478,10 +481,10 @@ test proves the ambient culture is ignored. recurring pass), and the announced thresholds are recorded so the bot never repeats itself. 14. **Monthly closing** — on the user's first local day of a month, the bot sends the closing of the previous one: total spent, expense count, a comparison with the month before, the - biggest categories with text bars and, when the month was overspent, the amount. A button - copies the closed month's budget into the new one. The closing is claimed once per user and - month, so a restart cannot repeat it; a month with no spending and no budget is skipped, so - the bot never talks about nothing. + biggest categories with text bars and, when the month was overspent, the amount. The new + month inherits the budget through its recurring defaults, so no button is needed. The closing + is claimed once per user and month, so a restart cannot repeat it; a month with no spending + and no budget is skipped, so the bot never talks about nothing. ## 12. MVP implementation phases @@ -491,7 +494,7 @@ test proves the ambient culture is ignored. | **1 Domain + persistence** | Entities, EF configurations, raw-SQL composite FKs, repositories, interceptor | Integration tests for constraints, user isolation, historical budgets — **done** | | **2 Money, dates, i18n** | Parser, formatter, compact parser, date parser, currency registry, message catalog | Parser corpus + property tests; ≥ 95 % coverage on money code — **done** (97,5 % money, 100 % dates) | | **3 Telegram plumbing** | Webhook, inbox idempotency, allowlist, advisory lock, conversation store and router, menu, onboarding | Local polling answers `/start`; duplicate update creates one row — **done** | -| **4 Categories & budgets** | Category CRUD, aliases, allocations, copy previous month | History tests; flow tests — **done** | +| **4 Categories & budgets** | Category CRUD, aliases, allocations, recurring and per-month budgets | History tests; flow tests — **done** | | **5 Expenses core** | Guided and compact entry, pending actions, confirmation, list, detail, edit, delete, undo | End-to-end flow tests — **done** | | **6 Matching** | Matcher, ambiguity, keyword learning, conflicts | Corpus including ambiguity; fuzzy off by default — **done** | | **7 Summary & statistics** | Dashboard, ranges, statistics, comparison | Snapshot tests of rendered messages — **done** | @@ -673,8 +676,14 @@ flows append. Scheduled closings added: the exactly-once marker claimed with an upsert (integration, including the second pass that finds the claim taken), the local-first-day decision, the empty-month skip and the previous-month report in the application service, the verbatim closing render with its -comparison and text bars, the scheduler pass that resolves the scoped service from a fresh scope, -and the global copy-budget callback. +comparison and text bars, and the scheduler pass that resolves the scoped service from a fresh +scope. + +Budget inheritance added: a default written one month is read by the next (application merge and +a real PostgreSQL round trip through the repositories), the one-off backfill turns the latest +allocation of each category into a default without inventing one for an earlier month, running it +twice does not duplicate a default, and promoting a month repeats it while a default that starts +later is left untouched. The restore drill is operational rather than unit-tested: it ran against the local stack on 2026-09-28 and its output and the corrupt-dump failure path are recorded in diff --git a/docs/specs/gotchas.md b/docs/specs/gotchas.md index 0d29a7a..fd09cde 100644 --- a/docs/specs/gotchas.md +++ b/docs/specs/gotchas.md @@ -90,6 +90,12 @@ whenever a gotcha is learned or disproven. otherwise `CompleteAsync` would write a `user_id` that no longer exists and hit the FK. - The recurring budget is a fallback, not a copy: `budget_defaults.effective_from` stops a default from appearing in months before it existed, and a month's own row always wins. +- Turning a month into a recurrence keeps the default's original `effective_from` (editing must + not make it apply to months it never covered) and leaves the override in place when the + default starts later, or the month would lose the allocation it is showing. The one-off + `BudgetDefaultsBackfill` takes the *latest* allocation of each category as the default, so it + never invents a budget for a month that had none, and `ON CONFLICT DO NOTHING` keeps it + idempotent. - A row referencing a category must be saved after the category exists in the same context: EF does not model the composite `(category_id, user_id)` foreign key, so `budget_defaults` (like `budget_alerts`) needs its own `SaveChanges`. diff --git a/src/MyBudget.Application/Budgets/BudgetService.cs b/src/MyBudget.Application/Budgets/BudgetService.cs index 3e97002..ebab6e6 100644 --- a/src/MyBudget.Application/Budgets/BudgetService.cs +++ b/src/MyBudget.Application/Budgets/BudgetService.cs @@ -58,6 +58,17 @@ public sealed record BudgetWriteResult(BudgetWriteStatus Status, MonthlyBudget? public static BudgetWriteResult CategoryInactive() => new(BudgetWriteStatus.CategoryInactive); } +/// +/// The outcome of turning a month's explicit allocations into recurring defaults. +/// is how many categories were made to recur: zero when the month +/// had no overrides, or when every one of them was already covered by a default that starts +/// after the month. +/// +public sealed record BudgetPromotionResult(BudgetWriteStatus Status, int PromotedCount) +{ + public bool Saved => Status == BudgetWriteStatus.Saved; +} + /// /// Monthly budget management, always for the current or a future month. /// @@ -92,6 +103,15 @@ Task SetAllocationAsync( Task RemoveAllocationAsync( Guid userId, MonthPeriod period, Guid categoryId, DateOnly today, CancellationToken cancellationToken = default); + + /// + /// Promotes every explicit allocation of the month into a recurring default, so later months + /// inherit the budget without an override of their own. A default that already exists keeps + /// the month it started; the month's own rows are cleared so it reads the default like any + /// other month. A past month is refused with a reason. + /// + Task PromoteMonthToDefaultsAsync( + Guid userId, MonthPeriod period, DateOnly today, CancellationToken cancellationToken = default); } /// @@ -208,6 +228,51 @@ public async Task RemoveAllocationAsync( return BudgetWriteResult.Ok(budget); } + public async Task PromoteMonthToDefaultsAsync( + Guid userId, MonthPeriod period, DateOnly today, CancellationToken cancellationToken = default) + { + if (IsPast(period, today)) + { + return new BudgetPromotionResult(BudgetWriteStatus.PastMonth, 0); + } + + var budget = await budgets.FindByPeriodAsync(userId, period, cancellationToken); + if (budget is null || budget.Allocations.Count == 0) + { + return new BudgetPromotionResult(BudgetWriteStatus.Saved, 0); + } + + var promoted = 0; + foreach (var allocation in budget.Allocations.ToList()) + { + var existing = await budgets.FindDefaultAsync(userId, allocation.CategoryId, cancellationToken); + + if (existing is null) + { + budgets.AddDefault(new BudgetDefault(userId, allocation.CategoryId, period, allocation.Amount)); + } + else if (!period.IsBefore(existing.EffectiveFrom)) + { + // Editing keeps the month the default took effect: it must not start applying to + // months it never covered. + existing.ChangeAmount(allocation.Amount); + } + else + { + // The default starts after this month, so it does not cover it. The override has + // to stay, or the month would lose the allocation it is showing. + continue; + } + + // The month now reads the default, exactly like every month after it. + budget.RemoveAllocation(allocation.CategoryId); + promoted++; + } + + await unitOfWork.SaveChangesAsync(cancellationToken); + return new BudgetPromotionResult(BudgetWriteStatus.Saved, promoted); + } + private static bool IsPast(MonthPeriod period, DateOnly today) => period.IsBefore(MonthPeriod.FromDate(today)); } diff --git a/src/MyBudget.Application/Localization/MessageKeys.cs b/src/MyBudget.Application/Localization/MessageKeys.cs index facfcb6..e98d9be 100644 --- a/src/MyBudget.Application/Localization/MessageKeys.cs +++ b/src/MyBudget.Application/Localization/MessageKeys.cs @@ -155,11 +155,13 @@ public static class MessageKeys public const string BudgetLineRecurring = "Budget.LineRecurring"; public const string BudgetTotal = "Budget.Total"; public const string BudgetButtonAssign = "Budget.ButtonAssign"; + public const string BudgetButtonPromote = "Budget.ButtonPromote"; public const string BudgetChooseCategory = "Budget.ChooseCategory"; public const string BudgetAmountPrompt = "Budget.AmountPrompt"; public const string BudgetAmountInvalid = "Budget.AmountInvalid"; public const string BudgetSaved = "Budget.Saved"; public const string BudgetSavedRecurring = "Budget.SavedRecurring"; + public const string BudgetPromoted = "Budget.Promoted"; public const string BudgetScopePrompt = "Budget.ScopePrompt"; public const string BudgetScopeMonth = "Budget.ScopeMonth"; public const string BudgetScopeAllMonths = "Budget.ScopeAllMonths"; @@ -409,11 +411,13 @@ public static class MessageKeys BudgetLineRecurring, BudgetTotal, BudgetButtonAssign, + BudgetButtonPromote, BudgetChooseCategory, BudgetAmountPrompt, BudgetAmountInvalid, BudgetSaved, BudgetSavedRecurring, + BudgetPromoted, BudgetScopePrompt, BudgetScopeMonth, BudgetScopeAllMonths, diff --git a/src/MyBudget.Application/Resources/Messages.resx b/src/MyBudget.Application/Resources/Messages.resx index 5122156..6f140df 100644 --- a/src/MyBudget.Application/Resources/Messages.resx +++ b/src/MyBudget.Application/Resources/Messages.resx @@ -417,6 +417,9 @@ ✏️ Asignar + + 🔁 Aplicar todos los meses + ¿A qué categoría le asigno presupuesto? @@ -432,6 +435,9 @@ ✅ Asigné {0} a {1} para todos los meses. + + ✅ Listo, este presupuesto se repite cada mes desde ahora. + ¿Aplica solo a {0} o a todos los meses? diff --git a/src/MyBudget.Infrastructure/Persistence/BudgetDefaultsBackfill.cs b/src/MyBudget.Infrastructure/Persistence/BudgetDefaultsBackfill.cs new file mode 100644 index 0000000..78ceb29 --- /dev/null +++ b/src/MyBudget.Infrastructure/Persistence/BudgetDefaultsBackfill.cs @@ -0,0 +1,52 @@ +namespace MyBudget.Infrastructure.Persistence; + +/// +/// The one-off data repair that makes an existing per-month budget recur. +/// +/// Before recurring budgets existed, every assignment was written as a +/// monthly_budget_categories row, so a user who configured September had nothing to +/// inherit in October. This turns the latest allocation of each category into a +/// budget_defaults row (effective from the month it was made) so later months inherit +/// it. A category that was never assigned gets no default, and a month before the newest +/// allocation is never granted one, so nothing that was already recorded is rewritten. +/// ON CONFLICT DO NOTHING keeps a re-run from duplicating a default that already exists. +/// +/// +/// It lives next to the migration rather than inline so the integration test can run the exact +/// same statement against a seeded database. +/// +/// +internal static class BudgetDefaultsBackfill +{ + public const string Sql = """ + INSERT INTO budget_defaults ( + id, user_id, category_id, + effective_from_year, effective_from_month, + amount, created_at, updated_at) + SELECT + gen_random_uuid(), + latest.user_id, + latest.category_id, + latest.year, + latest.month, + latest.amount, + now(), + now() + FROM ( + SELECT DISTINCT ON (allocation.user_id, allocation.category_id) + allocation.user_id, + allocation.category_id, + budget.year, + budget.month, + allocation.amount + FROM monthly_budget_categories AS allocation + JOIN monthly_budgets AS budget ON budget.id = allocation.monthly_budget_id + ORDER BY + allocation.user_id, + allocation.category_id, + budget.year DESC, + budget.month DESC + ) AS latest + ON CONFLICT (user_id, category_id) DO NOTHING; + """; +} diff --git a/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.Designer.cs b/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.Designer.cs new file mode 100644 index 0000000..fe8857e --- /dev/null +++ b/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.Designer.cs @@ -0,0 +1,843 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyBudget.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyBudget.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(MyBudgetDbContext))] + [Migration("20261001185217_BackfillBudgetDefaults")] + partial class BackfillBudgetDefaults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.31") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MyBudget.Domain.Budgets.BudgetDefault", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("EffectiveFromMonth") + .HasColumnType("smallint") + .HasColumnName("effective_from_month"); + + b.Property("EffectiveFromYear") + .HasColumnType("smallint") + .HasColumnName("effective_from_year"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CategoryId") + .IsUnique() + .HasDatabaseName("uq_budget_defaults_user_category"); + + b.ToTable("budget_defaults", null, t => + { + t.HasCheckConstraint("ck_budget_defaults_amount", "amount >= 0"); + + t.HasCheckConstraint("ck_budget_defaults_month", "effective_from_month BETWEEN 1 AND 12"); + + t.HasCheckConstraint("ck_budget_defaults_year", "effective_from_year BETWEEN 2000 AND 2100"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.MonthlyBudget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Month") + .HasColumnType("smallint") + .HasColumnName("month"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Year") + .HasColumnType("smallint") + .HasColumnName("year"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique() + .HasDatabaseName("uq_monthly_budgets_period"); + + b.ToTable("monthly_budgets", null, t => + { + t.HasCheckConstraint("ck_monthly_budgets_month", "month BETWEEN 1 AND 12"); + + t.HasCheckConstraint("ck_monthly_budgets_year", "year BETWEEN 2000 AND 2100"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.MonthlyBudgetCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("MonthlyBudgetId") + .HasColumnType("uuid") + .HasColumnName("monthly_budget_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("MonthlyBudgetId", "CategoryId") + .IsUnique() + .HasDatabaseName("uq_monthly_budget_categories_budget_category"); + + b.ToTable("monthly_budget_categories", null, t => + { + t.HasCheckConstraint("ck_monthly_budget_categories_amount", "amount >= 0"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Categories.BudgetCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("icon"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("categories", null, t => + { + t.HasCheckConstraint("ck_categories_icon", "char_length(icon) <= 16"); + + t.HasCheckConstraint("ck_categories_name", "btrim(name) <> '' AND char_length(name) <= 60"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Categories.CategoryAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("alias"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("NormalizedAlias") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)") + .HasColumnName("normalized_alias"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId", "NormalizedAlias") + .IsUnique() + .HasDatabaseName("uq_category_aliases_category_normalized"); + + b.HasIndex("UserId", "NormalizedAlias") + .HasDatabaseName("ix_category_aliases_user_normalized"); + + b.ToTable("category_aliases", null, t => + { + t.HasCheckConstraint("ck_category_aliases_alias", "btrim(alias) <> '' AND char_length(alias) <= 60"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Expenses.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("CategorizationSource") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("categorization_source"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ExpenseDate") + .HasColumnType("date") + .HasColumnName("expense_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CategoryId", "ExpenseDate") + .HasDatabaseName("ix_expenses_user_category_date"); + + b.HasIndex("UserId", "ExpenseDate", "Id") + .IsDescending(false, true, true) + .HasDatabaseName("ix_expenses_user_date"); + + b.ToTable("expenses", null, t => + { + t.HasCheckConstraint("ck_expenses_amount", "amount > 0 AND amount <= 999999999999"); + + t.HasCheckConstraint("ck_expenses_description", "description IS NULL OR char_length(description) <= 500"); + + t.HasCheckConstraint("ck_expenses_source", "categorization_source IN ('manual','matched','ambiguous','learned')"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Recurring.RecurringExpense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Amount") + .HasColumnType("bigint") + .HasColumnName("amount"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("DayOfMonth") + .HasColumnType("smallint") + .HasColumnName("day_of_month"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("LastGeneratedDate") + .HasColumnType("date") + .HasColumnName("last_generated_date"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive") + .HasDatabaseName("ix_recurring_expenses_user_active"); + + b.ToTable("recurring_expenses", null, t => + { + t.HasCheckConstraint("ck_recurring_expenses_amount", "amount > 0 AND amount <= 999999999999"); + + t.HasCheckConstraint("ck_recurring_expenses_day", "day_of_month BETWEEN 1 AND 31"); + + t.HasCheckConstraint("ck_recurring_expenses_description", "description IS NULL OR char_length(description) <= 500"); + + t.HasCheckConstraint("ck_recurring_expenses_period", "end_date IS NULL OR end_date >= start_date"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character(3)") + .HasColumnName("currency") + .IsFixedLength(); + + b.Property("DailyReminderEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("daily_reminder_enabled"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("display_name"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(5) + .HasColumnType("character varying(5)") + .HasColumnName("language"); + + b.Property("TelegramUserId") + .HasColumnType("bigint") + .HasColumnName("telegram_user_id"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("time_zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.Property("Username") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("username"); + + b.HasKey("Id"); + + b.HasIndex("TelegramUserId") + .IsUnique() + .HasDatabaseName("uq_users_telegram_user_id"); + + b.ToTable("users", null, t => + { + t.HasCheckConstraint("ck_users_currency", "currency ~ '^[A-Z]{3}$'"); + + t.HasCheckConstraint("ck_users_language", "language ~ '^[a-z]{2}(-[A-Z]{2})?$'"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.BudgetAlertRecord", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CategoryId") + .HasColumnType("uuid") + .HasColumnName("category_id"); + + b.Property("Month") + .HasColumnType("smallint") + .HasColumnName("month"); + + b.Property("NotifiedAt") + .HasColumnType("timestamptz") + .HasColumnName("notified_at"); + + b.Property("Threshold") + .HasColumnType("smallint") + .HasColumnName("threshold"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Year") + .HasColumnType("smallint") + .HasColumnName("year"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CategoryId", "Year", "Month", "Threshold") + .IsUnique() + .HasDatabaseName("uq_budget_alerts_user_category_period_threshold"); + + b.ToTable("budget_alerts", null, t => + { + t.HasCheckConstraint("ck_budget_alerts_month", "month BETWEEN 1 AND 12"); + + t.HasCheckConstraint("ck_budget_alerts_threshold", "threshold IN (80, 100)"); + + t.HasCheckConstraint("ck_budget_alerts_year", "year BETWEEN 2000 AND 2100"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.ConversationStateRecord", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ChatId") + .HasColumnType("bigint") + .HasColumnName("chat_id"); + + b.Property("Conversation") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("conversation"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamptz") + .HasColumnName("expires_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("state"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz") + .HasColumnName("updated_at"); + + b.HasKey("UserId"); + + b.ToTable("conversation_states", null, t => + { + t.HasCheckConstraint("ck_conversation_states_non_empty", "btrim(conversation) <> '' AND btrim(state) <> ''"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.MonthlyClosingRecord", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Month") + .HasColumnType("smallint") + .HasColumnName("month"); + + b.Property("SentAt") + .HasColumnType("timestamptz") + .HasColumnName("sent_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Year") + .HasColumnType("smallint") + .HasColumnName("year"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique() + .HasDatabaseName("uq_monthly_closings_user_period"); + + b.ToTable("monthly_closings", null, t => + { + t.HasCheckConstraint("ck_monthly_closings_month", "month BETWEEN 1 AND 12"); + + t.HasCheckConstraint("ck_monthly_closings_year", "year BETWEEN 2000 AND 2100"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.PendingActionRecord", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("action"); + + b.Property("ConsumedAt") + .HasColumnType("timestamptz") + .HasColumnName("consumed_at"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz") + .HasColumnName("created_at"); + + b.Property("ExpiresAt") + .HasColumnType("timestamptz") + .HasColumnName("expires_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt") + .HasDatabaseName("ix_pending_actions_expires_at"); + + b.HasIndex("UserId"); + + b.ToTable("pending_actions", null, t => + { + t.HasCheckConstraint("ck_pending_actions_non_empty", "btrim(action) <> ''"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.ReminderDeliveryRecord", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("kind"); + + b.Property("LocalDate") + .HasColumnType("date") + .HasColumnName("local_date"); + + b.Property("SentAt") + .HasColumnType("timestamptz") + .HasColumnName("sent_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind", "LocalDate") + .IsUnique() + .HasDatabaseName("uq_reminder_deliveries_user_kind_date"); + + b.ToTable("reminder_deliveries", null, t => + { + t.HasCheckConstraint("ck_reminder_deliveries_kind", "btrim(kind) <> ''"); + }); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.TelegramUpdateRecord", b => + { + b.Property("UpdateId") + .HasColumnType("bigint") + .HasColumnName("update_id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("LastError") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("last_error"); + + b.Property("ProcessedAt") + .HasColumnType("timestamptz") + .HasColumnName("processed_at"); + + b.Property("ReceivedAt") + .HasColumnType("timestamptz") + .HasColumnName("received_at"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasColumnName("status"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("UpdateId"); + + b.HasIndex("ReceivedAt") + .HasDatabaseName("ix_telegram_updates_received_at"); + + b.HasIndex("UserId"); + + b.ToTable("telegram_updates", null, t => + { + t.HasCheckConstraint("ck_telegram_updates_status", "status IN ('received','processed','failed','ignored')"); + }); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.BudgetDefault", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.MonthlyBudget", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.MonthlyBudgetCategory", b => + { + b.HasOne("MyBudget.Domain.Budgets.MonthlyBudget", null) + .WithMany("Allocations") + .HasForeignKey("MonthlyBudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Categories.BudgetCategory", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Categories.CategoryAlias", b => + { + b.HasOne("MyBudget.Domain.Categories.BudgetCategory", null) + .WithMany("Aliases") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Expenses.Expense", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Domain.Recurring.RecurringExpense", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.BudgetAlertRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.ConversationStateRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.MonthlyClosingRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.PendingActionRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.ReminderDeliveryRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MyBudget.Infrastructure.Persistence.Records.TelegramUpdateRecord", b => + { + b.HasOne("MyBudget.Domain.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("MyBudget.Domain.Budgets.MonthlyBudget", b => + { + b.Navigation("Allocations"); + }); + + modelBuilder.Entity("MyBudget.Domain.Categories.BudgetCategory", b => + { + b.Navigation("Aliases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.cs b/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.cs new file mode 100644 index 0000000..5143086 --- /dev/null +++ b/src/MyBudget.Infrastructure/Persistence/Migrations/20261001185217_BackfillBudgetDefaults.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyBudget.Infrastructure.Persistence.Migrations +{ + /// + public partial class BackfillBudgetDefaults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Data repair, no schema change: an allocation that predates recurring budgets + // becomes the recurring default of its category. See BudgetDefaultsBackfill. + migrationBuilder.Sql(BudgetDefaultsBackfill.Sql); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Deliberately empty. The rows this migration inserts are indistinguishable from + // defaults the user created afterwards, so removing them on a rollback would delete + // real configuration. Rolling back is a redeploy, not a data deletion. + } + } +} diff --git a/src/MyBudget.Telegram/Conversations/CategoriesConversation.Budgets.cs b/src/MyBudget.Telegram/Conversations/CategoriesConversation.Budgets.cs index d843f6b..60e10b2 100644 --- a/src/MyBudget.Telegram/Conversations/CategoriesConversation.Budgets.cs +++ b/src/MyBudget.Telegram/Conversations/CategoriesConversation.Budgets.cs @@ -20,6 +20,7 @@ internal sealed partial class CategoriesConversation { private const string BudgetCallback = CallbackPrefix + "budget"; private const string BudgetAssignCallback = CallbackPrefix + "budget:assign"; + private const string BudgetPromoteCallback = CallbackPrefix + "budget:promote"; private const string BudgetSetPrefix = CallbackPrefix + "budget:set:"; private const string BudgetScopeMonthCallback = CallbackPrefix + "budget:scope:month"; private const string BudgetScopeAllCallback = CallbackPrefix + "budget:scope:all"; @@ -63,11 +64,25 @@ private async Task BuildBudgetScreenAsync( moneyFormatter.Format(view.TotalAllocated, context.User.Currency)); } - var keyboard = BotKeyboard.Inline( - [ - [new BotButton(messages.Get(language, MessageKeys.BudgetButtonAssign), BudgetAssignCallback)], - [new BotButton(messages.Get(language, MessageKeys.CategoryButtonBack), ListCallback)], - ]); + var rows = new List + { + new[] { new BotButton(messages.Get(language, MessageKeys.BudgetButtonAssign), BudgetAssignCallback) }, + }; + + // Only offer the promotion when there is something to promote: a month that already + // reads its recurring defaults has nothing to make recur. + if (view.Lines.Any(line => line.Amount > 0 && !line.IsRecurring)) + { + rows.Add(new[] + { + new BotButton( + messages.Get(language, MessageKeys.BudgetButtonPromote), BudgetPromoteCallback), + }); + } + + rows.Add([new BotButton(messages.Get(language, MessageKeys.CategoryButtonBack), ListCallback)]); + + var keyboard = BotKeyboard.Inline([.. rows]); var responses = new List(notices) { BotResponse.Message(text, keyboard) }; @@ -214,6 +229,19 @@ private async Task ApplyAsync( return await BuildBudgetCategoryPickerAsync(context, cancellationToken, []); } + if (data == BudgetPromoteCallback) + { + var result = await budgets.PromoteMonthToDefaultsAsync( + context.User.Id, CurrentPeriod(context), localDate.Today(context.User.TimeZone), + cancellationToken); + + var notice = result.Status == BudgetWriteStatus.PastMonth + ? Said(context, MessageKeys.BudgetPastMonth) + : Said(context, MessageKeys.BudgetPromoted); + + return await BuildBudgetScreenAsync(context, cancellationToken, [notice]); + } + if (data == BudgetScopeMonthCallback || data == BudgetScopeAllCallback) { if (payload is { CategoryId: not null, Amount: { } amount }) diff --git a/tests/MyBudget.Application.Tests/Budgets/BudgetServiceTests.cs b/tests/MyBudget.Application.Tests/Budgets/BudgetServiceTests.cs index 16758d2..5be794b 100644 --- a/tests/MyBudget.Application.Tests/Budgets/BudgetServiceTests.cs +++ b/tests/MyBudget.Application.Tests/Budgets/BudgetServiceTests.cs @@ -267,4 +267,86 @@ public async Task A_future_month_is_editable() result.Saved.Should().BeTrue(); } + + [Fact] + public async Task Promoting_a_month_creates_defaults_from_that_month() + { + var category = new BudgetCategory(UserId, "Mercado"); + var budget = new MonthlyBudget(UserId, September); + budget.SetAllocation(category.Id, 300_000); + _budgets.FindByPeriodAsync(UserId, September, Arg.Any()).Returns(budget); + + var result = await _service.PromoteMonthToDefaultsAsync(UserId, September, Today); + + result.Saved.Should().BeTrue(); + result.PromotedCount.Should().Be(1); + _budgets.Received(1).AddDefault(Arg.Is(created => + created.CategoryId == category.Id + && created.Amount == 300_000 + && created.EffectiveFrom == September)); + budget.Allocations.Should().BeEmpty(); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Promoting_keeps_an_existing_defaults_effective_from() + { + var category = new BudgetCategory(UserId, "Mercado"); + var existing = new BudgetDefault(UserId, category.Id, September, 300_000); + var budget = new MonthlyBudget(UserId, October); + budget.SetAllocation(category.Id, 500_000); + _budgets.FindByPeriodAsync(UserId, October, Arg.Any()).Returns(budget); + _budgets.FindDefaultAsync(UserId, category.Id, Arg.Any()).Returns(existing); + + var result = await _service.PromoteMonthToDefaultsAsync(UserId, October, Today); + + result.PromotedCount.Should().Be(1); + existing.Amount.Should().Be(500_000); + existing.EffectiveFrom.Should().Be(September); + budget.Allocations.Should().BeEmpty(); + _budgets.DidNotReceive().AddDefault(Arg.Any()); + } + + [Fact] + public async Task Promoting_leaves_a_default_that_starts_later_untouched() + { + var category = new BudgetCategory(UserId, "Mercado"); + var future = new BudgetDefault(UserId, category.Id, new MonthPeriod(2026, 11), 300_000); + var budget = new MonthlyBudget(UserId, October); + budget.SetAllocation(category.Id, 500_000); + _budgets.FindByPeriodAsync(UserId, October, Arg.Any()).Returns(budget); + _budgets.FindDefaultAsync(UserId, category.Id, Arg.Any()).Returns(future); + + var result = await _service.PromoteMonthToDefaultsAsync(UserId, October, Today); + + result.PromotedCount.Should().Be(0); + future.Amount.Should().Be(300_000); + future.EffectiveFrom.Should().Be(new MonthPeriod(2026, 11)); + + // The override has to stay, or October would lose the allocation it is showing. + budget.Allocations.Should().ContainSingle(); + } + + [Fact] + public async Task Promoting_a_month_without_overrides_promotes_nothing() + { + _budgets.FindByPeriodAsync(UserId, September, Arg.Any()) + .Returns((MonthlyBudget?)null); + + var result = await _service.PromoteMonthToDefaultsAsync(UserId, September, Today); + + result.Saved.Should().BeTrue(); + result.PromotedCount.Should().Be(0); + _budgets.DidNotReceive().AddDefault(Arg.Any()); + } + + [Fact] + public async Task Promoting_a_past_month_is_refused_with_a_reason() + { + var result = await _service.PromoteMonthToDefaultsAsync(UserId, August, Today); + + result.Status.Should().Be(BudgetWriteStatus.PastMonth); + result.PromotedCount.Should().Be(0); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } } diff --git a/tests/MyBudget.Application.Tests/Reporting/ReportServiceTests.cs b/tests/MyBudget.Application.Tests/Reporting/ReportServiceTests.cs index 8c68022..1b87fbc 100644 --- a/tests/MyBudget.Application.Tests/Reporting/ReportServiceTests.cs +++ b/tests/MyBudget.Application.Tests/Reporting/ReportServiceTests.cs @@ -198,6 +198,29 @@ public async Task A_finished_month_is_not_flagged_incomplete() statistics.AverageDaily.Should().Be(0); } + [Fact] + public async Task The_summary_of_the_next_month_includes_the_recurring_default() + { + var food = new BudgetCategory(UserId, "Comida", "🍔"); + var october = new MonthPeriod(2026, 10); + + // October has no row of its own: the budget set in September is what has to reach it. + _budgets.FindByPeriodAsync(UserId, october, Arg.Any()) + .Returns((MonthlyBudget?)null); + _budgets.ListDefaultsAsync(UserId, Arg.Any()) + .Returns([new BudgetDefault(UserId, food.Id, September, 1_000_000)]); + _expenseQueries.SumByCategoryAsync(UserId, Arg.Any(), Arg.Any()) + .Returns(Array.Empty()); + _categories.ListAsync(UserId, true, Arg.Any()).Returns([food]); + + var summary = await _service.GetMonthlySummaryAsync(UserId, october); + + summary.TotalAllocated.Should().Be(1_000_000); + var line = summary.Lines.Single(); + line.Budget.Should().Be(1_000_000); + line.Spent.Should().Be(0); + } + private static Expense NewExpense(Guid categoryId, long amount, DateOnly date) => new(UserId, categoryId, amount, "Algo", date, Today); } diff --git a/tests/MyBudget.Infrastructure.Tests/BudgetInheritanceTests.cs b/tests/MyBudget.Infrastructure.Tests/BudgetInheritanceTests.cs new file mode 100644 index 0000000..c35dace --- /dev/null +++ b/tests/MyBudget.Infrastructure.Tests/BudgetInheritanceTests.cs @@ -0,0 +1,134 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using MyBudget.Application.Budgets; +using MyBudget.Domain.Budgets; +using MyBudget.Infrastructure.Persistence; +using MyBudget.Infrastructure.Persistence.Repositories; + +namespace MyBudget.Infrastructure.Tests; + +/// +/// The budget-inheritance contract end to end against PostgreSQL. +/// +/// A budget reaches the next month through budget_defaults, and the one-off backfill is +/// what turns a legacy per-month allocation into that default. These tests fail if a future +/// change stops a later month from inheriting, which is exactly the October regression. +/// +/// +public sealed class BudgetInheritanceTests(DatabaseFixture fixture) : DatabaseTestBase(fixture) +{ + private static readonly MonthPeriod August = new(2026, 8); + private static readonly MonthPeriod September = new(2026, 9); + private static readonly MonthPeriod October = new(2026, 10); + + [Fact] + public async Task The_backfill_turns_the_latest_allocation_of_each_category_into_a_default() + { + await using var context = CreateContext(); + var user = TestData.NewUser(); + var food = TestData.NewCategory(user.Id, "Mercado"); + context.Users.Add(user); + context.Categories.Add(food); + await context.SaveChangesAsync(); + + AddAllocation(context, user.Id, food.Id, September, 900_000); + AddAllocation(context, user.Id, food.Id, October, 1_500_000); + await context.SaveChangesAsync(); + + await RunBackfillAsync(context); + + var defaults = await new BudgetRepository(context).ListDefaultsAsync(user.Id); + + defaults.Should().ContainSingle(); + defaults[0].CategoryId.Should().Be(food.Id); + // The newest allocation wins; the default starts there, never earlier. + defaults[0].Amount.Should().Be(1_500_000); + defaults[0].EffectiveFrom.Should().Be(October); + } + + [Fact] + public async Task The_next_month_inherits_the_backfilled_budget_and_an_earlier_one_does_not() + { + await using var context = CreateContext(); + var user = TestData.NewUser(); + var food = TestData.NewCategory(user.Id, "Mercado"); + context.Users.Add(user); + context.Categories.Add(food); + await context.SaveChangesAsync(); + + AddAllocation(context, user.Id, food.Id, September, 1_000_000); + await context.SaveChangesAsync(); + + await RunBackfillAsync(context); + + var service = new BudgetService( + new BudgetRepository(context), new CategoryRepository(context), new UnitOfWork(context)); + + var august = await service.GetMonthAsync(user.Id, August); + var september = await service.GetMonthAsync(user.Id, September); + var october = await service.GetMonthAsync(user.Id, October); + + // Before the default existed: nothing is invented retroactively. + august.TotalAllocated.Should().Be(0); + + // The month the user set keeps its own snapshot. + september.TotalAllocated.Should().Be(1_000_000); + september.Lines.Single().IsRecurring.Should().BeFalse(); + + // And the next month inherits it without the user doing anything. + october.TotalAllocated.Should().Be(1_000_000); + october.Lines.Single().IsRecurring.Should().BeTrue(); + } + + [Fact] + public async Task Running_the_backfill_twice_does_not_duplicate_a_default() + { + await using var context = CreateContext(); + var user = TestData.NewUser(); + var food = TestData.NewCategory(user.Id, "Mercado"); + context.Users.Add(user); + context.Categories.Add(food); + await context.SaveChangesAsync(); + + AddAllocation(context, user.Id, food.Id, September, 1_000_000); + await context.SaveChangesAsync(); + + await RunBackfillAsync(context); + await RunBackfillAsync(context); + + var defaults = await new BudgetRepository(context).ListDefaultsAsync(user.Id); + defaults.Should().ContainSingle(); + } + + [Fact] + public async Task A_category_that_was_never_assigned_gets_no_default() + { + await using var context = CreateContext(); + var user = TestData.NewUser(); + var funded = TestData.NewCategory(user.Id, "Mercado"); + var neverFunded = TestData.NewCategory(user.Id, "Ocio", "🎬"); + context.Users.Add(user); + context.Categories.Add(funded); + context.Categories.Add(neverFunded); + await context.SaveChangesAsync(); + + AddAllocation(context, user.Id, funded.Id, September, 1_000_000); + await context.SaveChangesAsync(); + + await RunBackfillAsync(context); + + var defaults = await new BudgetRepository(context).ListDefaultsAsync(user.Id); + defaults.Should().ContainSingle().Which.CategoryId.Should().Be(funded.Id); + } + + private static void AddAllocation( + MyBudgetDbContext context, Guid userId, Guid categoryId, MonthPeriod period, long amount) + { + var budget = TestData.NewBudget(userId, period.Year, period.Month); + budget.SetAllocation(categoryId, amount); + context.MonthlyBudgets.Add(budget); + } + + private static Task RunBackfillAsync(MyBudgetDbContext context) => + context.Database.ExecuteSqlRawAsync(BudgetDefaultsBackfill.Sql); +} diff --git a/tests/MyBudget.Infrastructure.Tests/HistoricalBudgetTests.cs b/tests/MyBudget.Infrastructure.Tests/HistoricalBudgetTests.cs index 18e0719..67a1025 100644 --- a/tests/MyBudget.Infrastructure.Tests/HistoricalBudgetTests.cs +++ b/tests/MyBudget.Infrastructure.Tests/HistoricalBudgetTests.cs @@ -56,8 +56,11 @@ public async Task Editing_a_later_month_never_rewrites_an_earlier_month() } [Fact] - public async Task A_later_month_does_not_inherit_the_previous_month_automatically() + public async Task An_explicit_month_allocation_is_a_snapshot_and_is_not_copied() { + // A storage-level guarantee, not the product rule: no row is materialised for October. + // A later month inherits a budget through budget_defaults (see BudgetInheritanceTests), + // never by silently copying another month's override into this month's table. await using var context = CreateContext(); var user = TestData.NewUser(); var category = TestData.NewCategory(user.Id, "Mercado"); diff --git a/tests/MyBudget.Telegram.Tests/CategoriesConversationBudgetTests.cs b/tests/MyBudget.Telegram.Tests/CategoriesConversationBudgetTests.cs index 461e71c..90904f0 100644 --- a/tests/MyBudget.Telegram.Tests/CategoriesConversationBudgetTests.cs +++ b/tests/MyBudget.Telegram.Tests/CategoriesConversationBudgetTests.cs @@ -237,4 +237,61 @@ public async Task Assigning_with_no_active_categories_explains_it() turn.Responses.Should().Contain(response => response.Text == harness.Messages.Get("es", MessageKeys.BudgetNoCategories)); } + + [Fact] + public async Task The_budget_screen_offers_to_make_the_month_recurring() + { + var harness = TelegramHarness.Build(); + StubMonth(harness, new MonthlyBudgetView( + September, + [new MonthlyBudgetLine(Guid.NewGuid(), "Mercado", "🛒", 500_000, IsActive: true)])); + + var turn = await harness.Router.RouteCallbackAsync( + ContextFor(harness, "menu", new CategoriesPayload()), + new IncomingCallback("cb", "cats:budget"), + CancellationToken.None); + + var buttons = turn.Responses.Last().Keyboard!.Rows.SelectMany(row => row).ToList(); + buttons.Should().Contain(button => + button.CallbackData == "cats:budget:promote" + && button.Text == harness.Messages.Get("es", MessageKeys.BudgetButtonPromote)); + } + + [Fact] + public async Task A_month_that_already_recurs_has_nothing_to_promote() + { + var harness = TelegramHarness.Build(); + StubMonth(harness, new MonthlyBudgetView( + September, + [new MonthlyBudgetLine(Guid.NewGuid(), "Vivienda", "🏠", 700_000, IsActive: true, IsRecurring: true)])); + + var turn = await harness.Router.RouteCallbackAsync( + ContextFor(harness, "menu", new CategoriesPayload()), + new IncomingCallback("cb", "cats:budget"), + CancellationToken.None); + + var buttons = turn.Responses.Last().Keyboard!.Rows.SelectMany(row => row).ToList(); + buttons.Should().NotContain(button => button.CallbackData == "cats:budget:promote"); + } + + [Fact] + public async Task Promoting_turns_the_month_into_recurring_and_confirms_it() + { + var harness = TelegramHarness.Build(); + StubMonth(harness, new MonthlyBudgetView(September, [])); + harness.BudgetService + .PromoteMonthToDefaultsAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new BudgetPromotionResult(BudgetWriteStatus.Saved, 3))); + + var turn = await harness.Router.RouteCallbackAsync( + ContextFor(harness, "budget", new CategoriesPayload()), + new IncomingCallback("cb", "cats:budget:promote"), + CancellationToken.None); + + await harness.BudgetService.Received(1).PromoteMonthToDefaultsAsync( + harness.User.Id, September, Today, Arg.Any()); + turn.Responses.Should().Contain(response => + response.Text == harness.Messages.Get("es", MessageKeys.BudgetPromoted)); + } } From 548ac4dac4a4b604282683495fa5933a06434f28 Mon Sep 17 00:00:00 2001 From: santidev21 Date: Thu, 1 Oct 2026 14:23:29 -0500 Subject: [PATCH 2/2] ci(sonarcloud): exclude EF migrations from the coverage gate A new migration's Up line is applied by the DatabaseFixture before coverage is attributed to it, so it is always reported at 0 %: the new-code coverage gate failed at 75 % on a single migration line. Migrations are scaffolding whose effect the integration tests prove, so exclude them (and the generated Designer files) from coverage. This keeps every future migration PR from hitting the same gate. --- .github/workflows/sonarcloud.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 09c5d4a..c3b76d4 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -68,6 +68,12 @@ jobs: "/d:sonar.token=$SONAR_TOKEN" "/d:sonar.cs.opencover.reportsPaths=**/coverage.opencover.xml" "/d:sonar.cs.vstest.reportsPaths=**/TestResults/*.trx" + # EF Core migrations are scaffolding whose effect is proven by the + # integration tests, not by their own line coverage. Without this, + # the migrator line that applies a new migration is always at 0 % + # (the fixture migrates before coverage is attributed) and every + # migration PR would fail the new-code coverage gate. + "/d:sonar.coverage.exclusions=**/Persistence/Migrations/**,**/*.Designer.cs" ) if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then args+=(