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: 3 additions & 3 deletions backend/src/Host.Mcp/FinanceMcpTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,12 @@ public Task<string> UpsertRecurring(
[Description("Name, e.g. Rent (max 80 characters). Required to create.")] string? name = null,
[Description("expense or income. Required to create.")] string? type = null,
[Description("Positive amount, at most 2 decimals. Required to create.")] decimal? amount = null,
[Description("weekly, monthly or yearly. Required to create.")] string? frequency = null,
[Description("daily, weekly, monthly or yearly (daily = every interval days from start_on). Required to create.")] string? frequency = null,
[Description("Account id (get_accounts \"id\"). Required to create.")] string? account_id = null,
[Description("Category key or name. Required to create.")] string? category = null,
[Description("First date yyyy-MM-dd (default today).")] string? start_on = null,
[Description("Day of the month (1-31) for monthly and yearly items.")] int? day_of_month = null,
[Description("Every N periods (1-24, default 1).")] int? interval = null,
[Description("Day of the month (1-31) for monthly and yearly items; not allowed for daily.")] int? day_of_month = null,
[Description("Every N periods: 1-365 for daily (every N days), 1-24 otherwise (default 1).")] int? interval = null,
[Description("Last date yyyy-MM-dd (optional).")] string? end_on = null,
[Description(KeyHelp)] string? idempotency_key = null,
CancellationToken ct = default) => Call("upsert_recurring", ct, ("recurring_id", recurring_id), ("name", name),
Expand Down
9 changes: 2 additions & 7 deletions backend/src/Modules/Ai/Ai.Application/Tools/FinanceTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,7 @@ decimal MonthlyEur(IEnumerable<RecurringTransaction> items) =>
category = r.CategoryId is { } c ? categories.GetValueOrDefault(c) : null,
nature = r.Nature?.ToString(),
monthlyEquivalentEur = decimal.Round(Eur(PerMonth(r), r.Currency), 2),
nextDueOn = r.NextDueOn,
nextDueOn = r.NextOccurrenceOnOrAfter(ctx.Today) ?? r.NextDueOn,
active = r.IsActive && (r.EndOn is null || r.EndOn >= ctx.Today),
}).ToList();

Expand Down Expand Up @@ -707,12 +707,7 @@ private async Task<ToolOutput> AllocationAsync(ToolContext ctx, CancellationToke
}, lines.Count);
}

private static decimal PerMonth(RecurringTransaction r) => r.Frequency switch
{
RecurrenceFrequency.Weekly => r.Amount * 52m / 12m / r.Interval,
RecurrenceFrequency.Monthly => r.Amount / r.Interval,
_ => r.Amount / 12m / r.Interval,
};
private static decimal PerMonth(RecurringTransaction r) => r.MonthlyEquivalent();

private static TransactionType[] TypesOf(string type) => type switch
{
Expand Down
23 changes: 18 additions & 5 deletions backend/src/Modules/Ai/Ai.Application/Tools/WriteTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,12 @@ private async Task<WriteOutput> UpsertRecurringAsync(WriteContext ctx, Cancellat
var name = a.Text("name", 80);
var typeText = a.OneOf("type", "expense", "income");
var amount = a.Decimal("amount", 0.01m, MaxAmount, 2);
var frequencyText = a.OneOf("frequency", "weekly", "monthly", "yearly");
var frequencyText = a.OneOf("frequency", "daily", "weekly", "monthly", "yearly");
var accountId = a.Id("account_id");
var categoryText = a.Category();
var startOn = a.Date("start_on");
var dayOfMonth = a.Integer("day_of_month", 1, 31);
var interval = a.Integer("interval", 1, 24);
var interval = a.Integer("interval", 1, RecurringTransaction.MaxDailyInterval);
var endOn = a.Date("end_on");
a.IdempotencyKey();

Expand Down Expand Up @@ -305,23 +305,30 @@ private async Task<WriteOutput> UpsertRecurringAsync(WriteContext ctx, Cancellat
var frequency = frequencyText is null
? existing?.Frequency ?? throw new ToolArgumentException("frequency is required to create a recurring item.")
: Enum.Parse<RecurrenceFrequency>(frequencyText, ignoreCase: true);
if (frequency == RecurrenceFrequency.Daily && dayOfMonth is not null)
{
throw new ToolArgumentException("day_of_month does not apply to daily items: they repeat every interval days from start_on.");
}

var nature = existing is not null && existing.Type == type && existing.CategoryId == categoryId ? existing.Nature : null;
var request = new RecurringRequest(
name ?? existing?.Name ?? throw new ToolArgumentException("name is required to create a recurring item."),
type.Value,
amount ?? existing?.Amount ?? throw new ToolArgumentException("amount is required to create a recurring item."),
account.Currency, account.Id, frequency, startOn ?? existing?.StartOn ?? ctx.Today,
interval ?? existing?.Interval ?? 1, dayOfMonth ?? existing?.DayOfMonth, endOn ?? existing?.EndOn,
interval ?? existing?.Interval ?? 1,
// A stored day of month means nothing for daily items (and is cleared when switching to daily).
frequency == RecurrenceFrequency.Daily ? null : dayOfMonth ?? existing?.DayOfMonth, endOn ?? existing?.EndOn,
categoryId, nature, null, null, existing?.Description);
await CheckAsync(recurringValidator, request, ct);

var categoryName = await CategoryNameAsync(categoryId, ct);
var saved = existing is null
? Ok(await RecurringCommands.CreateAsync(finance, proposer, request.ToDefinition(), ct))
: Ok(await RecurringCommands.UpdateAsync(finance, existing.Id, request.ToDefinition(), ct));
: Ok(await RecurringCommands.UpdateAsync(finance, proposer, existing.Id, request.ToDefinition(), ct));
var verb = existing is null ? "Created" : "Updated";
return new(Result(saved.Id, "recurring",
$"{verb} the recurring {TypeName(saved.Type)} of {Money(saved.Amount, saved.Currency)} ({saved.Frequency.ToString().ToLowerInvariant()}{(saved.Interval > 1 ? $", every {saved.Interval}" : "")}) in {categoryName}; next due {Day(saved.NextDueOn)}.",
$"{verb} the recurring {TypeName(saved.Type)} of {Money(saved.Amount, saved.Currency)} ({ScheduleText(saved)}) in {categoryName}; next due {Day(saved.NextOccurrenceOnOrAfter(ctx.Today) ?? saved.NextDueOn)}.",
new { name = UntrustedText.From(saved.Name) }),
saved.Id, existing is null ? null : RecurringSnapshot(existing, await CategoryNameAsync(existing.CategoryId, ct)),
RecurringSnapshot(saved, categoryName));
Expand Down Expand Up @@ -611,6 +618,12 @@ private static object Result(Guid id, string kind, string summary, object? detai
category, accountId = t.AccountId, toAccountId = t.CounterAccountId, description = t.Description,
};

private static string ScheduleText(RecurringTransaction r) => r.Frequency switch
{
RecurrenceFrequency.Daily => r.Interval == 1 ? "every day" : $"every {r.Interval} days",
_ => $"{r.Frequency.ToString().ToLowerInvariant()}{(r.Interval > 1 ? $", every {r.Interval}" : "")}",
};

private static object RecurringSnapshot(RecurringTransaction r, string? category) => new
{
name = r.Name, type = TypeName(r.Type), amount = r.Amount, currency = r.Currency,
Expand Down
6 changes: 3 additions & 3 deletions backend/src/Modules/Ai/Ai.Contracts/AiTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,12 @@ private static ToolParameter Id(string name, string what) =>
new("name", "string", "Name, e.g. Rent (max 80 characters). Required to create."),
new("type", "string", "expense or income. Required to create.", Enum: ["expense", "income"]),
new("amount", "number", "Positive amount, at most 2 decimals. Required to create."),
new("frequency", "string", "weekly, monthly or yearly. Required to create.", Enum: ["weekly", "monthly", "yearly"]),
new("frequency", "string", "daily, weekly, monthly or yearly (daily = every interval days from start_on). Required to create.", Enum: ["daily", "weekly", "monthly", "yearly"]),
new("account_id", "string", "Account id (get_accounts \"id\"). Required to create."),
new("category", "string", "Category key or name. Required to create."),
new("start_on", "string", "First date yyyy-MM-dd (default today)."),
new("day_of_month", "integer", "Day of the month (1-31) for monthly and yearly items."),
new("interval", "integer", "Every N periods (1-24, default 1)."),
new("day_of_month", "integer", "Day of the month (1-31) for monthly and yearly items; not allowed for daily."),
new("interval", "integer", "Every N periods: 1-365 for daily (every N days), 1-24 otherwise (default 1)."),
new("end_on", "string", "Last date yyyy-MM-dd (optional)."),
IdempotencyKey,
], Write: true, Idempotent: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ public static async Task<Result<RecurringTransaction>> CreateAsync(IFinanceDb db
return recurring;
}

public static async Task<Result<RecurringTransaction>> UpdateAsync(IFinanceDb db, Guid id,
RecurringDefinition definition, CancellationToken ct)
/// <summary>
/// Edits a template. When its schedule changes, pending proposals dated today or later are dropped and proposed
/// again from the new anchor; confirmed and skipped ones, and overdue pending ones, stay as they are.
/// </summary>
public static async Task<Result<RecurringTransaction>> UpdateAsync(IFinanceDb db, RecurringProposer proposer,
Guid id, RecurringDefinition definition, CancellationToken ct)
{
var recurring = await db.RecurringTransactions.FindAsync([id], ct);
if (recurring is null)
Expand All @@ -39,8 +43,22 @@ public static async Task<Result<RecurringTransaction>> UpdateAsync(IFinanceDb db
return error;
}

recurring.Update(definition);
var today = proposer.Today;
var scheduleChanged = recurring.Update(definition, today);
if (scheduleChanged)
{
var stale = await db.ExpectedTransactions
.Where(e => e.RecurringTransactionId == id && e.Status == ExpectedStatus.Pending && e.DueOn >= today)
.ToListAsync(ct);
db.ExpectedTransactions.RemoveRange(stale);
}

await db.SaveChangesAsync(ct);
if (scheduleChanged)
{
await proposer.ProposeDueAsync(ct);
}

return recurring;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,13 @@ public RecurringRequestValidator()
RuleFor(x => x.Amount).GreaterThan(0);
RuleFor(x => x.Type).IsInEnum();
RuleFor(x => x.Frequency).IsInEnum();
RuleFor(x => x.Interval).InclusiveBetween(1, 24).When(x => x.Interval is not null);
RuleFor(x => x.Interval).InclusiveBetween(1, RecurringTransaction.MaxDailyInterval)
.When(x => x.Interval is not null && x.Frequency == RecurrenceFrequency.Daily);
RuleFor(x => x.Interval).InclusiveBetween(1, RecurringTransaction.MaxInterval)
.When(x => x.Interval is not null && x.Frequency != RecurrenceFrequency.Daily);
RuleFor(x => x.DayOfMonth).InclusiveBetween(1, 31).When(x => x.DayOfMonth is not null);
RuleFor(x => x.DayOfMonth).Null().When(x => x.Frequency == RecurrenceFrequency.Daily)
.WithMessage("Day of month does not apply to daily items: they repeat every N days from the start date.");
RuleFor(x => x.EndOn).GreaterThanOrEqualTo(x => x.StartOn).When(x => x.EndOn is not null);
RuleFor(x => x.Currency).Must(c => c is null || Currency.IsValid(c));
RuleFor(x => x.Description).MaximumLength(200);
Expand Down Expand Up @@ -96,21 +101,29 @@ public static void MapRecurring(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/recurring").WithTags("Recurring");

group.MapGet("/", async (IFinanceDb db, CancellationToken ct) =>
Results.Ok(await db.RecurringTransactions.AsNoTracking().OrderBy(r => r.NextDueOn)
group.MapGet("/", async (IFinanceDb db, RecurringProposer proposer, CancellationToken ct) =>
{
// NextDueOn is the first scheduled date on or after today, even when it is already awaiting confirmation.
var today = proposer.Today;
var templates = await db.RecurringTransactions.AsNoTracking().ToListAsync(ct);
return Results.Ok(templates
.Select(r => new RecurringDto(r.Id, r.Name, r.Type, r.Amount, r.Currency, r.AccountId,
r.CounterAccountId, r.CategoryId, r.Nature, r.BucketId, r.Description, r.Frequency, r.Interval,
r.DayOfMonth, r.StartOn, r.EndOn, r.NextDueOn, r.IsActive))
.ToListAsync(ct)));
r.DayOfMonth, r.StartOn, r.EndOn, r.NextOccurrenceOnOrAfter(today) ?? r.NextDueOn, r.IsActive))
.OrderBy(r => r.NextDueOn)
.ToList());
});

group.MapPost("/", async (RecurringRequest req, IFinanceDb db, RecurringProposer proposer,
CancellationToken ct) =>
(await RecurringCommands.CreateAsync(db, proposer, req.ToDefinition(), ct))
.ToHttp(r => Results.Created($"/api/recurring/{r.Id}", new { r.Id })))
.Validate<RecurringRequest>();

group.MapPut("/{id:guid}", async (Guid id, RecurringRequest req, IFinanceDb db, CancellationToken ct) =>
(await RecurringCommands.UpdateAsync(db, id, req.ToDefinition(), ct)).ToHttp(_ => Results.NoContent()))
group.MapPut("/{id:guid}", async (Guid id, RecurringRequest req, IFinanceDb db, RecurringProposer proposer,
CancellationToken ct) =>
(await RecurringCommands.UpdateAsync(db, proposer, id, req.ToDefinition(), ct))
.ToHttp(_ => Results.NoContent()))
.Validate<RecurringRequest>();

group.MapPost("/{id:guid}/pause", (Guid id, IFinanceDb db, CancellationToken ct) => SetActive(id, false, db, ct));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ public sealed class RecurringProposer(IFinanceDb db, TimeProvider clock)
/// <summary>How far ahead proposals are generated, so upcoming bills are visible before they are due.</summary>
public const int LookaheadDays = 7;

/// <summary>The date schedules are evaluated against (UTC calendar day, like the rest of the app).</summary>
public DateOnly Today => DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);

public async Task<int> ProposeDueAsync(CancellationToken ct)
{
var now = clock.GetUtcNow();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public enum RecurrenceFrequency
Weekly = 0,
Monthly = 1,
Yearly = 2,

/// <summary>Every <c>Interval</c> days from <c>StartOn</c> (e.g. a gym billed every 15 days).</summary>
Daily = 3,
}

/// <summary>
Expand All @@ -32,6 +35,12 @@ private RecurringTransaction() { }
public RecurrenceFrequency Frequency { get; private set; }
public int Interval { get; private set; } = 1;

/// <summary>Largest <see cref="Interval"/> for <see cref="RecurrenceFrequency.Daily"/> (a year of days).</summary>
public const int MaxDailyInterval = 365;

/// <summary>Largest <see cref="Interval"/> for weekly, monthly and yearly items.</summary>
public const int MaxInterval = 24;

/// <summary>For monthly/yearly: day of month (clamped to month length, so 31 means "last day").</summary>
public int? DayOfMonth { get; private set; }
public DateOnly StartOn { get; private set; }
Expand All @@ -49,17 +58,53 @@ public static RecurringTransaction Create(RecurringDefinition def)
return r;
}

public void Update(RecurringDefinition def)
/// <summary>
/// Applies <paramref name="def"/>. When the schedule changes (frequency, interval, day, start or end), upcoming
/// occurrences are recalculated from the new anchor starting at <paramref name="today"/>: past occurrences are
/// never re-proposed, and the caller drops the pending proposals dated today or later so they can be regenerated.
/// </summary>
/// <returns>Whether the schedule changed.</returns>
public bool Update(RecurringDefinition def, DateOnly today)
{
var scheduleChanged = def.Frequency != Frequency || def.Interval != Interval ||
def.DayOfMonth != DayOfMonth || def.StartOn != StartOn;
var before = (Frequency, Interval, DayOfMonth, StartOn, EndOn);
Apply(def);
var scheduleChanged = before != (Frequency, Interval, DayOfMonth, StartOn, EndOn);
if (scheduleChanged)
{
NextDueOn = FirstOccurrenceOnOrAfter(def.StartOn > NextDueOn ? def.StartOn : NextDueOn);
NextDueOn = FirstOccurrenceOnOrAfter(StartOn > today ? StartOn : today);
}

return scheduleChanged;
}

/// <summary>
/// The first scheduled occurrence on or after <paramref name="today"/> (whether or not it was already proposed),
/// or <c>null</c> when the schedule ends before then.
/// </summary>
public DateOnly? NextOccurrenceOnOrAfter(DateOnly today)
{
var next = FirstOccurrenceOnOrAfter(today);
return EndOn is { } end && next > end ? null : next;
}

/// <summary>
/// Average amount per month: weeks and days use 365-day years (<c>amount × 365 / (days between occurrences × 12)</c>),
/// months and years divide by the number of months between occurrences.
/// </summary>
public static decimal MonthlyEquivalent(decimal amount, RecurrenceFrequency frequency, int interval)
{
var every = Math.Max(1, interval);
return frequency switch
{
RecurrenceFrequency.Daily => amount * 365m / (every * 12m),
RecurrenceFrequency.Weekly => amount * 365m / (7m * every * 12m),
RecurrenceFrequency.Monthly => amount / every,
_ => amount / (12m * every),
};
}

public decimal MonthlyEquivalent() => MonthlyEquivalent(Amount, Frequency, Interval);

public void SetActive(bool active) => IsActive = active;

/// <summary>Returns due dates up to <paramref name="horizon"/> and advances the schedule past them.</summary>
Expand Down Expand Up @@ -93,22 +138,25 @@ private void Apply(RecurringDefinition def)
Description = def.Description;
Frequency = def.Frequency;
Interval = Math.Max(1, def.Interval);
DayOfMonth = def.Frequency == RecurrenceFrequency.Weekly ? null : def.DayOfMonth ?? def.StartOn.Day;
// Day of month only anchors monthly and yearly items; weekly and daily ones count days from StartOn.
DayOfMonth = IsDayBased(def.Frequency) ? null : def.DayOfMonth ?? def.StartOn.Day;
StartOn = def.StartOn;
EndOn = def.EndOn;
}

private DateOnly FirstOccurrenceOnOrAfter(DateOnly from)
{
if (Frequency == RecurrenceFrequency.Weekly)
if (IsDayBased(Frequency))
{
var d = StartOn;
while (d < from)
// Occurrences are StartOn + k × step days (k = 0, 1, …): pure date arithmetic, so DST never moves one.
if (from <= StartOn)
{
d = d.AddDays(7 * Interval);
return StartOn;
}

return d;
var step = StepDays();
var periods = (from.DayNumber - StartOn.DayNumber + step - 1) / step;
return StartOn.AddDays(periods * step);
}

var candidate = Anchor(StartOn.Year, StartOn.Month);
Expand All @@ -127,11 +175,16 @@ private DateOnly FirstOccurrenceOnOrAfter(DateOnly from)

private DateOnly Advance(DateOnly current) => Frequency switch
{
RecurrenceFrequency.Weekly => current.AddDays(7 * Interval),
RecurrenceFrequency.Weekly or RecurrenceFrequency.Daily => current.AddDays(StepDays()),
RecurrenceFrequency.Monthly => AnchorFrom(current.AddMonths(Interval)),
_ => AnchorFrom(current.AddYears(Interval)),
};

private static bool IsDayBased(RecurrenceFrequency frequency) =>
frequency is RecurrenceFrequency.Weekly or RecurrenceFrequency.Daily;

private int StepDays() => Frequency == RecurrenceFrequency.Weekly ? 7 * Interval : Interval;

private DateOnly AnchorFrom(DateOnly d) => Anchor(d.Year, d.Month);

private DateOnly Anchor(int year, int month) =>
Expand Down
Loading
Loading