feat(api): add transaction idempotency keys (#1729)

* feat(api): add transaction idempotency keys

* fix(api): validate transaction idempotency source

* fix(api): tighten transaction idempotency params
This commit is contained in:
ghost
2026-05-11 14:13:24 -07:00
committed by GitHub
parent 96c893ec18
commit 974f55e2d5
6 changed files with 371 additions and 3 deletions

View File

@@ -179,6 +179,220 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_equal @account.id, response_data["account"]["id"]
end
test "should create transaction with external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "import-txn-1",
source: "external_import"
}
}
assert_difference("@account.entries.count", 1) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
assert_equal "import-txn-1", response_data["external_id"]
assert_equal "external_import", response_data["source"]
entry = @account.entries.find_by!(external_id: "import-txn-1", source: "external_import")
assert_equal response_data["id"], entry.transaction.id
end
test "should use default source when external_id provided without source" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "default-source-test"
}
}
assert_difference("@account.entries.count", 1) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :created
response_data = JSON.parse(response.body)
entry = @account.entries.find_by!(external_id: "default-source-test")
assert_equal "api", entry.source
assert_equal "api", response_data["source"]
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
headers: api_headers(@api_key)
end
assert_response :ok
end
test "should reject source without external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
source: "external_import"
}
}
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "Source requires external_id", response_data["message"]
assert_equal [ "Source requires external_id" ], response_data["errors"]
end
test "should return existing transaction for duplicate external idempotency key" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "import-txn-2",
source: "external_import"
}
}
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
assert_response :created
created_data = JSON.parse(response.body)
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { name: "Changed Name" }),
headers: api_headers(@api_key)
end
assert_response :ok
response_data = JSON.parse(response.body)
assert_equal created_data["id"], response_data["id"]
assert_equal "Imported Transaction", response_data["name"]
end
test "should scope external idempotency keys to account" do
other_account = @family.accounts.create!(
name: "Other API Account",
accountable: Depository.new,
balance: 0,
currency: "USD"
)
transaction_params = {
transaction: {
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "shared-import-txn",
source: "external_import"
}
}
assert_difference("Entry.count", 2) do
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { account_id: @account.id }),
headers: api_headers(@api_key)
assert_response :created
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { account_id: other_account.id }),
headers: api_headers(@api_key)
assert_response :created
end
end
test "should scope external idempotency keys to source" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current,
currency: "USD",
nature: "expense",
external_id: "shared-source-txn",
source: "external_import"
}
}
assert_difference("Entry.count", 2) do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
assert_response :created
post api_v1_transactions_url,
params: transaction_params.deep_merge(transaction: { source: "other_import" }),
headers: api_headers(@api_key)
assert_response :created
end
@account.entries.find_by!(external_id: "shared-source-txn", source: "external_import")
@account.entries.find_by!(external_id: "shared-source-txn", source: "other_import")
end
test "should reject external idempotency key collision with non-transaction entry" do
@account.entries.create!(
name: "Existing valuation",
amount: 100,
currency: "USD",
date: Date.current,
external_id: "import-non-transaction",
source: "external_import",
entryable: Valuation.new
)
post api_v1_transactions_url,
params: {
transaction: {
account_id: @account.id,
name: "Imported Transaction",
amount: 25.00,
date: Date.current - 1.day,
currency: "USD",
nature: "expense",
external_id: "import-non-transaction",
source: "external_import"
}
},
headers: api_headers(@api_key)
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
end
test "should reject create with read-only API key" do
transaction_params = {
transaction: {
@@ -209,6 +423,31 @@ class Api::V1::TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_response :unprocessable_entity
end
test "should reject invalid date on create" do
transaction_params = {
transaction: {
account_id: @account.id,
name: "Invalid Date Transaction",
amount: 25.00,
date: "not-a-date",
currency: "USD",
nature: "expense"
}
}
assert_no_difference("@account.entries.count") do
post api_v1_transactions_url,
params: transaction_params,
headers: api_headers(@api_key)
end
assert_response :unprocessable_entity
response_data = JSON.parse(response.body)
assert_equal "validation_failed", response_data["error"]
assert_equal "Transaction could not be created", response_data["message"]
assert response_data["errors"].any? { |error| error.match?(/Date/) }
end
test "should reject create without API key" do
post api_v1_transactions_url, params: { transaction: { name: "Test" } }
assert_response :unauthorized