Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/sonarcloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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+=(
Expand Down
12 changes: 10 additions & 2 deletions docs/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down Expand Up @@ -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**

Expand Down
33 changes: 21 additions & 12 deletions docs/TECHNICAL-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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

Expand All @@ -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** |
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/specs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
65 changes: 65 additions & 0 deletions src/MyBudget.Application/Budgets/BudgetService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ public sealed record BudgetWriteResult(BudgetWriteStatus Status, MonthlyBudget?
public static BudgetWriteResult CategoryInactive() => new(BudgetWriteStatus.CategoryInactive);
}

/// <summary>
/// The outcome of turning a month's explicit allocations into recurring defaults.
/// <see cref="PromotedCount"/> 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.
/// </summary>
public sealed record BudgetPromotionResult(BudgetWriteStatus Status, int PromotedCount)
{
public bool Saved => Status == BudgetWriteStatus.Saved;
}

/// <summary>
/// Monthly budget management, always for the current or a future month.
/// <para>
Expand Down Expand Up @@ -92,6 +103,15 @@ Task<BudgetWriteResult> SetAllocationAsync(
Task<BudgetWriteResult> RemoveAllocationAsync(
Guid userId, MonthPeriod period, Guid categoryId, DateOnly today,
CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
Task<BudgetPromotionResult> PromoteMonthToDefaultsAsync(
Guid userId, MonthPeriod period, DateOnly today, CancellationToken cancellationToken = default);
}

/// <inheritdoc />
Expand Down Expand Up @@ -208,6 +228,51 @@ public async Task<BudgetWriteResult> RemoveAllocationAsync(
return BudgetWriteResult.Ok(budget);
}

public async Task<BudgetPromotionResult> 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));
}
4 changes: 4 additions & 0 deletions src/MyBudget.Application/Localization/MessageKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -409,11 +411,13 @@ public static class MessageKeys
BudgetLineRecurring,
BudgetTotal,
BudgetButtonAssign,
BudgetButtonPromote,
BudgetChooseCategory,
BudgetAmountPrompt,
BudgetAmountInvalid,
BudgetSaved,
BudgetSavedRecurring,
BudgetPromoted,
BudgetScopePrompt,
BudgetScopeMonth,
BudgetScopeAllMonths,
Expand Down
6 changes: 6 additions & 0 deletions src/MyBudget.Application/Resources/Messages.resx
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,9 @@
<data name="Budget.ButtonAssign" xml:space="preserve">
<value>✏️ Asignar</value>
</data>
<data name="Budget.ButtonPromote" xml:space="preserve">
<value>🔁 Aplicar todos los meses</value>
</data>
<data name="Budget.ChooseCategory" xml:space="preserve">
<value>¿A qué categoría le asigno presupuesto?</value>
</data>
Expand All @@ -432,6 +435,9 @@
<data name="Budget.SavedRecurring" xml:space="preserve">
<value>✅ Asigné {0} a {1} para todos los meses.</value>
</data>
<data name="Budget.Promoted" xml:space="preserve">
<value>✅ Listo, este presupuesto se repite cada mes desde ahora.</value>
</data>
<data name="Budget.ScopePrompt" xml:space="preserve">
<value>¿Aplica solo a {0} o a todos los meses?</value>
</data>
Expand Down
52 changes: 52 additions & 0 deletions src/MyBudget.Infrastructure/Persistence/BudgetDefaultsBackfill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
namespace MyBudget.Infrastructure.Persistence;

/// <summary>
/// The one-off data repair that makes an existing per-month budget recur.
/// <para>
/// Before recurring budgets existed, every assignment was written as a
/// <c>monthly_budget_categories</c> row, so a user who configured September had nothing to
/// inherit in October. This turns the <em>latest</em> allocation of each category into a
/// <c>budget_defaults</c> 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.
/// <c>ON CONFLICT DO NOTHING</c> keeps a re-run from duplicating a default that already exists.
/// </para>
/// <para>
/// It lives next to the migration rather than inline so the integration test can run the exact
/// same statement against a seeded database.
/// </para>
/// </summary>
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;
""";
}
Loading
Loading