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+=( 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)); + } }