Files
sure/app/models/assistant/function/update_transaction.rb
Martin Molcrette 5f0f5ec89d feat(mcp): Add MCP transaction update tool (#2719)
* Add MCP transaction update tool

* Fix MCP transaction authorization

* Ignore Pipelock false positive on SnapTrade token lookup

Pipelock scan-diff flags `token = oauth_refresh_token...` as
"Credential in URL" even though these are ActiveRecord attribute
names, not embedded secrets. Add the established inline ignore.

Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Martin Molcrette <Mart1M@users.noreply.github.com>
2026-08-01 20:57:43 +02:00

187 lines
5.8 KiB
Ruby

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