mirror of
https://github.com/we-promise/sure.git
synced 2026-08-04 16:12:14 +00:00
* 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>
187 lines
5.8 KiB
Ruby
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
|