Merge branch 'main' into claude/sure-patrimonial-history-c8s7am

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-08-01 11:59:42 -07:00
committed by GitHub
7 changed files with 358 additions and 3 deletions

View File

@@ -134,7 +134,11 @@ class Assistant::Function::GetTransactions < Assistant::Function
def call(params = {})
search_params = params.except("order", "page")
search = Transaction::Search.new(family, filters: search_params)
search = Transaction::Search.new(
family,
filters: search_params,
accessible_account_ids: user.accessible_accounts.visible.pluck(:id)
)
transactions_query = search.transactions_scope
pagy_query = params["order"] == "asc" ? transactions_query.chronological : transactions_query.reverse_chronological
@@ -155,6 +159,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
normalized_transactions = paginated_transactions.map do |txn|
entry = txn.entry
{
id: txn.id,
name: entry.name,
date: entry.date,
amount: entry.amount.abs,
@@ -162,6 +167,7 @@ class Assistant::Function::GetTransactions < Assistant::Function
formatted_amount: entry.amount_money.abs.format,
classification: entry.amount < 0 ? "income" : "expense",
account: entry.account.name,
notes: entry.notes,
category: txn.category&.name,
merchant: txn.merchant&.name,
tags: txn.tags.map(&:name),

View File

@@ -0,0 +1,186 @@
class Assistant::Function::UpdateTransaction < Assistant::Function
class << self
def name
"update_transaction"
end
def description
<<~INSTRUCTIONS
Updates an existing transaction.
Use get_transactions first to find the transaction id, and get_categories,
get_tags, or the current transaction merchant before referencing related ids.
This tool can update the transaction name, notes, category, merchant, and
tags. It will not edit split child transactions directly.
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: [ "id" ],
properties: {
id: {
type: "string",
description: "Transaction ID from get_transactions"
},
name: {
type: "string",
description: "New transaction name. Omit to leave unchanged."
},
notes: {
type: [ "string", "null" ],
description: "New transaction notes. Use null to clear notes. Omit to leave unchanged."
},
category_id: {
type: [ "string", "null" ],
description: "Category ID from get_categories. Use null to clear category. Omit to leave unchanged."
},
merchant_id: {
type: [ "string", "null" ],
description: "Merchant ID currently available to the family. Use null to clear merchant. Omit to leave unchanged."
},
tag_ids: {
type: "array",
items: { type: "string" },
description: "Full list of tag IDs to set. Use an empty array to clear all tags. Omit to leave unchanged."
}
}
)
end
def call(params = {})
transaction = find_transaction(params["id"])
return error("not_found", "Transaction with id '#{params["id"]}' not found.") unless transaction
entry = transaction.entry
return error("split_child", "Split child transactions cannot be edited directly. Use the split editor.") if entry.split_child?
return error("not_authorized", "You do not have permission to update this transaction.") unless permitted_to_update?(entry.account, params)
entry_attrs = entry_attributes(params, entry)
return entry_attrs if error_response?(entry_attrs)
tag_ids = nil
if params.key?("tag_ids")
tag_ids = Array(params["tag_ids"]).map(&:to_s).reject(&:blank?)
return error("invalid_tags", "One or more tag_ids do not belong to the user's family.") unless valid_tag_ids?(tag_ids)
end
return error("no_changes", "Provide at least one field to update.") if no_changes?(entry_attrs, params)
Entry.transaction do
entry.update!(entry_attrs)
if params.key?("tag_ids")
transaction.tag_ids = tag_ids
transaction.save!
transaction.lock_attr!(:tag_ids)
end
entry.sync_account_later
entry.lock_saved_attributes!
end
{
success: true,
transaction: serialize(transaction.reload),
message: "Transaction '#{transaction.entry.name}' updated."
}
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
end
private
def find_transaction(id)
return nil unless valid_uuid?(id)
family.transactions
.joins(:entry)
.where(entries: { account_id: user.accessible_accounts.visible.select(:id) })
.find_by(id: id)
end
def permitted_to_update?(account, params)
permission = account.permission_for(user)
return true if permission.in?([ :owner, :full_control ])
permission == :read_write && !params.key?("name")
end
def entry_attributes(params, entry)
entryable_attrs = { id: entry.entryable_id }
if params.key?("category_id")
category_id = optional_uuid(params["category_id"])
return category_id if error_response?(category_id)
return error("invalid_category", "category_id does not belong to the user's family.") if category_id && !family.categories.exists?(id: category_id)
entryable_attrs[:category_id] = category_id
end
if params.key?("merchant_id")
merchant_id = optional_uuid(params["merchant_id"])
return merchant_id if error_response?(merchant_id)
return error("invalid_merchant", "merchant_id is not available to the user's family.") if merchant_id && !available_merchants.exists?(id: merchant_id)
entryable_attrs[:merchant_id] = merchant_id
end
attrs = {}
attrs[:name] = params["name"].to_s.strip if params.key?("name")
attrs[:notes] = params["notes"] if params.key?("notes")
attrs[:entryable_attributes] = entryable_attrs if entryable_attrs.keys.size > 1
attrs
end
def optional_uuid(value)
return nil if value.nil? || value == ""
return value.to_s if valid_uuid?(value)
error("invalid_uuid", "Expected a valid UUID.")
end
def valid_tag_ids?(tag_ids)
family.tags.where(id: tag_ids).count == tag_ids.uniq.size
end
def available_merchants
family.available_merchants_for(user)
end
def no_changes?(entry_attrs, params)
entry_attrs.empty? && !params.key?("tag_ids")
end
def serialize(transaction)
entry = transaction.entry
{
id: transaction.id,
name: entry.name,
date: entry.date,
notes: entry.notes,
category: transaction.category && {
id: transaction.category.id,
name: transaction.category.name
},
merchant: transaction.merchant && {
id: transaction.merchant.id,
name: transaction.merchant.name
},
tags: transaction.tags.map { |tag| { id: tag.id, name: tag.name } }
}
end
def error_response?(value)
value.is_a?(Hash) && value[:success] == false
end
def error(key, message)
{ success: false, error: key, message: message }
end
end