mirror of
https://github.com/we-promise/sure.git
synced 2026-06-01 00:39:01 +00:00
Merge remote-tracking branch 'origin/main' into feat/goals-v2-architecture
# Conflicts: # .github/workflows/preview-deploy.yml # app/models/account/provider_import_adapter.rb
This commit is contained in:
200
app/models/assistant/function/get_budget.rb
Normal file
200
app/models/assistant/function/get_budget.rb
Normal file
@@ -0,0 +1,200 @@
|
||||
class Assistant::Function::GetBudget < Assistant::Function
|
||||
include ActiveSupport::NumberHelper
|
||||
|
||||
MAX_PRIOR_MONTHS = 11
|
||||
|
||||
class << self
|
||||
def name
|
||||
"get_budget"
|
||||
end
|
||||
|
||||
def description
|
||||
<<~INSTRUCTIONS
|
||||
Use this to see how the user is tracking against their monthly budget — total
|
||||
budgeted vs spent and a parent/subcategory breakdown matching the budget UI.
|
||||
|
||||
This is great for answering questions like:
|
||||
- How am I tracking against my budget this month?
|
||||
- Which categories am I over budget on?
|
||||
- How does this month's spending compare to the last few months?
|
||||
|
||||
Parameters:
|
||||
- `month` (optional): "YYYY-MM" or "MMM-YYYY". Defaults to the current month.
|
||||
- `prior_months` (optional): integer 0..#{MAX_PRIOR_MONTHS}. Number of months
|
||||
preceding the target month to include for trend comparison. Default 0.
|
||||
|
||||
Example (current month only):
|
||||
|
||||
```
|
||||
get_budget({})
|
||||
```
|
||||
|
||||
Example (current month plus last 2 months):
|
||||
|
||||
```
|
||||
get_budget({ month: "#{Date.current.strftime('%Y-%m')}", prior_months: 2 })
|
||||
```
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
|
||||
def strict_mode?
|
||||
false
|
||||
end
|
||||
|
||||
def params_schema
|
||||
build_schema(
|
||||
properties: {
|
||||
month: {
|
||||
type: "string",
|
||||
description: "Target month in YYYY-MM or MMM-YYYY format. Defaults to the current month."
|
||||
},
|
||||
prior_months: {
|
||||
type: "integer",
|
||||
description: "Number of months before the target month to also return for trend comparison.",
|
||||
minimum: 0,
|
||||
maximum: MAX_PRIOR_MONTHS
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def call(params = {})
|
||||
target_start = resolve_month_start(params["month"])
|
||||
prior = [ params["prior_months"].to_i, 0 ].max
|
||||
prior = [ prior, MAX_PRIOR_MONTHS ].min
|
||||
|
||||
month_starts = (0..prior).map { |offset| shift_months(target_start, -offset) }.reverse
|
||||
requested = month_starts.count { |start_date| Budget.budget_date_valid?(start_date, family: family) }
|
||||
|
||||
months = month_starts.filter_map do |start_date|
|
||||
next unless Budget.budget_date_valid?(start_date, family: family)
|
||||
build_month_payload(start_date, bootstrap: start_date == target_start)
|
||||
end
|
||||
|
||||
result = {
|
||||
currency: family.currency,
|
||||
months: months
|
||||
}
|
||||
unavailable = requested - months.length
|
||||
result[:months_unavailable] = unavailable if unavailable > 0
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
def build_month_payload(start_date, bootstrap:)
|
||||
budget = if bootstrap
|
||||
Budget.find_or_bootstrap(family, start_date: start_date, user: user)
|
||||
else
|
||||
budget_start, budget_end = Budget.period_for(start_date, family: family)
|
||||
family.budgets.find_by(start_date: budget_start, end_date: budget_end)
|
||||
end
|
||||
return nil unless budget
|
||||
|
||||
groups = BudgetCategory::Group.for(budget.budget_categories)
|
||||
|
||||
{
|
||||
month: budget.to_param,
|
||||
period: {
|
||||
start_date: budget.start_date,
|
||||
end_date: budget.end_date
|
||||
},
|
||||
is_current: budget.current?,
|
||||
initialized: budget.initialized?,
|
||||
totals: {
|
||||
budgeted_spending: format_money(budget.budgeted_spending),
|
||||
allocated_spending: format_money(budget.allocated_spending),
|
||||
available_to_allocate: format_money(budget.available_to_allocate),
|
||||
actual_spending: format_money(budget.actual_spending),
|
||||
available_to_spend: format_money(budget.available_to_spend),
|
||||
percent_of_budget_spent: format_percent(budget.initialized? ? budget.percent_of_budget_spent : 0),
|
||||
overage_percent: format_percent(budget.overage_percent)
|
||||
},
|
||||
income: {
|
||||
expected_income: format_money(budget.expected_income),
|
||||
actual_income: format_money(budget.actual_income),
|
||||
remaining_expected_income: format_money((budget.expected_income || 0) - budget.actual_income)
|
||||
},
|
||||
categories: groups.map { |group| serialize_group(group, include_daily_suggestion: budget.current?) }
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_group(group, include_daily_suggestion:)
|
||||
parent = group.budget_category
|
||||
serialize_category(parent, include_daily_suggestion: include_daily_suggestion).merge(
|
||||
color: parent.category.color,
|
||||
subcategories: group.budget_subcategories.map do |sub|
|
||||
serialize_category(sub, include_daily_suggestion: include_daily_suggestion).merge(
|
||||
inherits_parent_budget: sub.inherits_parent_budget?
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
def serialize_category(bc, include_daily_suggestion:)
|
||||
payload = {
|
||||
name: bc.name,
|
||||
budgeted: format_money(bc.display_budgeted_spending),
|
||||
actual: format_money(bc.actual_spending),
|
||||
available: format_money(bc.available_to_spend),
|
||||
percent_spent: format_percent(bc.percent_of_budget_spent || 0),
|
||||
status: category_status(bc)
|
||||
}
|
||||
|
||||
if include_daily_suggestion
|
||||
suggestion = bc.suggested_daily_spending
|
||||
payload[:suggested_daily_spending] = suggestion[:amount].format if suggestion
|
||||
end
|
||||
|
||||
payload
|
||||
end
|
||||
|
||||
def category_status(bc)
|
||||
return "over_budget" if bc.over_budget_with_budget?
|
||||
return "unbudgeted" if bc.unbudgeted_with_spending?
|
||||
return "near_limit" if bc.budgeted? && bc.near_limit?
|
||||
return "on_track" if bc.on_track?
|
||||
"no_activity"
|
||||
end
|
||||
|
||||
def resolve_month_start(raw)
|
||||
base = parse_month(raw)
|
||||
return (base || Date.current).beginning_of_month unless family.uses_custom_month_start?
|
||||
|
||||
# Match Budget.param_to_date for explicit slugs so the input round-trips with the response.
|
||||
base ? Date.new(base.year, base.month, family.month_start_day) : family.custom_month_start_for(Date.current)
|
||||
end
|
||||
|
||||
def parse_month(raw)
|
||||
return nil if raw.blank?
|
||||
|
||||
# Date.strptime ignores trailing characters, so guard with strict anchors first.
|
||||
fmt = case raw
|
||||
when /\A\d{4}-\d{2}\z/ then "%Y-%m"
|
||||
when /\A[A-Za-z]{3}-\d{4}\z/ then "%b-%Y"
|
||||
end
|
||||
|
||||
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY." if fmt.nil?
|
||||
|
||||
Date.strptime(raw, fmt)
|
||||
rescue ArgumentError
|
||||
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY."
|
||||
end
|
||||
|
||||
def shift_months(date, n)
|
||||
shifted = date >> n
|
||||
if family.uses_custom_month_start?
|
||||
family.custom_month_start_for(shifted)
|
||||
else
|
||||
shifted.beginning_of_month
|
||||
end
|
||||
end
|
||||
|
||||
def format_money(value)
|
||||
Money.new(value || 0, family.currency).format
|
||||
end
|
||||
|
||||
def format_percent(value)
|
||||
number_to_percentage(value || 0, precision: 1)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user