mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 23:25:00 +00:00
feat(exports): preserve transfer decisions (#1639)
* feat(exports): preserve transfer decisions * fix(api): apply transfer date filters to both sides * fix(api): refine transfer decision handling * fix(api): align transfer decision schemas * fix(api): use current context for transfer filters * fix(api): include either side in transfer date filters * fix(api): deduplicate transfer decision filters * fix(api): guard transfer decision exports
This commit is contained in:
182
test/controllers/api/v1/rejected_transfers_controller_test.rb
Normal file
182
test/controllers/api/v1/rejected_transfers_controller_test.rb
Normal file
@@ -0,0 +1,182 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::RejectedTransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Key",
|
||||
scopes: [ "read" ],
|
||||
source: "web",
|
||||
display_key: "test_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@account = @family.accounts.create!(
|
||||
name: "Rejected Checking",
|
||||
accountable: Depository.new,
|
||||
balance: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
@destination_account = @family.accounts.create!(
|
||||
name: "Rejected Savings",
|
||||
accountable: Depository.new,
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
outflow = create_transaction(@account, amount: 25, date: Date.parse("2024-01-15"), name: "Rejected outflow")
|
||||
inflow = create_transaction(@destination_account, amount: -25, date: Date.parse("2024-01-15"), name: "Rejected inflow")
|
||||
@rejected_transfer = RejectedTransfer.create!(
|
||||
outflow_transaction: outflow,
|
||||
inflow_transaction: inflow
|
||||
)
|
||||
|
||||
other_family = families(:empty)
|
||||
other_account = other_family.accounts.create!(name: "Other Rejected Checking", accountable: Depository.new, balance: 0, currency: "USD")
|
||||
other_destination = other_family.accounts.create!(name: "Other Rejected Savings", accountable: Depository.new, balance: 0, currency: "USD")
|
||||
other_outflow = create_transaction(other_account, amount: 50, date: Date.parse("2024-01-15"), name: "Other rejected outflow")
|
||||
other_inflow = create_transaction(other_destination, amount: -50, date: Date.parse("2024-01-15"), name: "Other rejected inflow")
|
||||
@other_rejected_transfer = RejectedTransfer.create!(outflow_transaction: other_outflow, inflow_transaction: other_inflow)
|
||||
end
|
||||
|
||||
test "lists rejected transfers scoped to the current family" do
|
||||
get api_v1_rejected_transfers_url, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("rejected_transfers")
|
||||
assert response_data.key?("pagination")
|
||||
assert_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @rejected_transfer.id
|
||||
assert_not_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @other_rejected_transfer.id
|
||||
end
|
||||
|
||||
test "permits read write scope" do
|
||||
read_write_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Write Key",
|
||||
scopes: [ "read_write" ],
|
||||
source: "mobile",
|
||||
display_key: "test_read_write_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
get api_v1_rejected_transfers_url, headers: api_headers(read_write_key)
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "shows a rejected transfer" do
|
||||
get api_v1_rejected_transfer_url(@rejected_transfer), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal @rejected_transfer.id, response_data["id"]
|
||||
assert_equal "Rejected Savings", response_data.dig("inflow_transaction", "account", "name")
|
||||
assert_equal "Rejected Checking", response_data.dig("outflow_transaction", "account", "name")
|
||||
end
|
||||
|
||||
test "returns not found for another family's rejected transfer" do
|
||||
get api_v1_rejected_transfer_url(@other_rejected_transfer), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "returns not found for malformed rejected transfer id" do
|
||||
get api_v1_rejected_transfer_url("not-a-uuid"), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "filters rejected transfers by account_id" do
|
||||
get api_v1_rejected_transfers_url, params: { account_id: @account.id }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["rejected_transfers"].map { |transfer| transfer["id"] }, @rejected_transfer.id
|
||||
end
|
||||
|
||||
test "rejects malformed account_id filter" do
|
||||
get api_v1_rejected_transfers_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid date filter" do
|
||||
get api_v1_rejected_transfers_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "filters rejected transfers when either transaction side date matches" do
|
||||
matched_outflow = create_transaction(@account, amount: 35, date: Date.parse("2024-02-10"), name: "Rejected dated outflow")
|
||||
matched_inflow = create_transaction(@destination_account, amount: -35, date: Date.parse("2024-02-10"), name: "Rejected dated inflow")
|
||||
date_matched_transfer = RejectedTransfer.create!(outflow_transaction: matched_outflow, inflow_transaction: matched_inflow)
|
||||
|
||||
partial_outflow = create_transaction(@account, amount: 45, date: Date.parse("2024-02-10"), name: "Rejected partial outflow")
|
||||
partial_inflow = create_transaction(@destination_account, amount: -45, date: Date.parse("2024-02-12"), name: "Rejected partial inflow")
|
||||
partial_date_transfer = RejectedTransfer.create!(outflow_transaction: partial_outflow, inflow_transaction: partial_inflow)
|
||||
|
||||
get api_v1_rejected_transfers_url,
|
||||
params: { start_date: "2024-02-10", end_date: "2024-02-10" },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
transfer_ids = JSON.parse(response.body)["rejected_transfers"].map { |transfer| transfer["id"] }
|
||||
assert_includes transfer_ids, date_matched_transfer.id
|
||||
assert_includes transfer_ids, partial_date_transfer.id
|
||||
assert_not_includes transfer_ids, @rejected_transfer.id
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
get api_v1_rejected_transfers_url
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires read scope" do
|
||||
# ApiKey.create! rejects empty scopes; bypass validation to exercise runtime authorization.
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "mobile",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_rejected_transfers_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_transaction(account, amount:, date:, name:)
|
||||
entry = account.entries.create!(
|
||||
date: date,
|
||||
amount: amount,
|
||||
name: name,
|
||||
currency: account.currency,
|
||||
entryable: Transaction.new(kind: "standard")
|
||||
)
|
||||
entry.entryable
|
||||
end
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.display_key }
|
||||
end
|
||||
end
|
||||
203
test/controllers/api/v1/transfers_controller_test.rb
Normal file
203
test/controllers/api/v1/transfers_controller_test.rb
Normal file
@@ -0,0 +1,203 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::V1::TransfersControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:family_admin)
|
||||
@family = @user.family
|
||||
@user.api_keys.active.destroy_all
|
||||
|
||||
@api_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Key",
|
||||
scopes: [ "read" ],
|
||||
source: "web",
|
||||
display_key: "test_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@account = @family.accounts.create!(
|
||||
name: "Transfer Checking",
|
||||
accountable: Depository.new,
|
||||
balance: 500,
|
||||
currency: "USD"
|
||||
)
|
||||
@destination_account = @family.accounts.create!(
|
||||
name: "Transfer Savings",
|
||||
accountable: Depository.new,
|
||||
balance: 1000,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
outflow = create_transaction(@account, amount: 100, date: Date.parse("2024-01-15"), name: "Transfer to savings")
|
||||
inflow = create_transaction(@destination_account, amount: -100, date: Date.parse("2024-01-15"), name: "Transfer from checking")
|
||||
@transfer = Transfer.create!(
|
||||
outflow_transaction: outflow,
|
||||
inflow_transaction: inflow,
|
||||
status: "confirmed",
|
||||
notes: "Confirmed by user"
|
||||
)
|
||||
|
||||
other_family = families(:empty)
|
||||
other_account = other_family.accounts.create!(name: "Other Checking", accountable: Depository.new, balance: 0, currency: "USD")
|
||||
other_destination = other_family.accounts.create!(name: "Other Savings", accountable: Depository.new, balance: 0, currency: "USD")
|
||||
other_outflow = create_transaction(other_account, amount: 50, date: Date.parse("2024-01-15"), name: "Other outflow")
|
||||
other_inflow = create_transaction(other_destination, amount: -50, date: Date.parse("2024-01-15"), name: "Other inflow")
|
||||
@other_transfer = Transfer.create!(outflow_transaction: other_outflow, inflow_transaction: other_inflow)
|
||||
end
|
||||
|
||||
test "lists transfers scoped to the current family" do
|
||||
get api_v1_transfers_url, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert response_data.key?("transfers")
|
||||
assert response_data.key?("pagination")
|
||||
assert_includes response_data["transfers"].map { |transfer| transfer["id"] }, @transfer.id
|
||||
assert_not_includes response_data["transfers"].map { |transfer| transfer["id"] }, @other_transfer.id
|
||||
end
|
||||
|
||||
test "permits read write scope" do
|
||||
read_write_key = ApiKey.create!(
|
||||
user: @user,
|
||||
name: "Test Read Write Key",
|
||||
scopes: [ "read_write" ],
|
||||
source: "mobile",
|
||||
display_key: "test_read_write_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
get api_v1_transfers_url, headers: api_headers(read_write_key)
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "shows a transfer" do
|
||||
get api_v1_transfer_url(@transfer), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal @transfer.id, response_data["id"]
|
||||
assert_equal "confirmed", response_data["status"]
|
||||
assert_equal "Confirmed by user", response_data["notes"]
|
||||
assert_equal "Transfer Savings", response_data.dig("inflow_transaction", "account", "name")
|
||||
assert_equal "Transfer Checking", response_data.dig("outflow_transaction", "account", "name")
|
||||
assert response_data.key?("amount_cents")
|
||||
end
|
||||
|
||||
test "returns not found for another family's transfer" do
|
||||
get api_v1_transfer_url(@other_transfer), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "returns not found for malformed transfer id" do
|
||||
get api_v1_transfer_url("not-a-uuid"), headers: api_headers(@api_key)
|
||||
|
||||
assert_response :not_found
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "record_not_found", response_data["error"]
|
||||
end
|
||||
|
||||
test "filters transfers by status" do
|
||||
get api_v1_transfers_url, params: { status: "confirmed" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal [ @transfer.id ], response_data["transfers"].map { |transfer| transfer["id"] }
|
||||
end
|
||||
|
||||
test "filters transfers by account_id" do
|
||||
get api_v1_transfers_url, params: { account_id: @account.id }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_includes response_data["transfers"].map { |transfer| transfer["id"] }, @transfer.id
|
||||
end
|
||||
|
||||
test "rejects malformed account_id filter" do
|
||||
get api_v1_transfers_url, params: { account_id: "not-a-uuid" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid status filter" do
|
||||
get api_v1_transfers_url, params: { status: "settled" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "rejects invalid date filter" do
|
||||
get api_v1_transfers_url, params: { start_date: "01/15/2024" }, headers: api_headers(@api_key)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
response_data = JSON.parse(response.body)
|
||||
assert_equal "validation_failed", response_data["error"]
|
||||
end
|
||||
|
||||
test "filters transfers when either transaction side date matches" do
|
||||
matched_outflow = create_transaction(@account, amount: 75, date: Date.parse("2024-02-10"), name: "Dated outflow")
|
||||
matched_inflow = create_transaction(@destination_account, amount: -75, date: Date.parse("2024-02-10"), name: "Dated inflow")
|
||||
date_matched_transfer = Transfer.create!(outflow_transaction: matched_outflow, inflow_transaction: matched_inflow)
|
||||
|
||||
partial_outflow = create_transaction(@account, amount: 80, date: Date.parse("2024-02-10"), name: "Partial outflow")
|
||||
partial_inflow = create_transaction(@destination_account, amount: -80, date: Date.parse("2024-02-12"), name: "Partial inflow")
|
||||
partial_date_transfer = Transfer.create!(outflow_transaction: partial_outflow, inflow_transaction: partial_inflow)
|
||||
|
||||
get api_v1_transfers_url,
|
||||
params: { start_date: "2024-02-10", end_date: "2024-02-10" },
|
||||
headers: api_headers(@api_key)
|
||||
|
||||
assert_response :success
|
||||
transfer_ids = JSON.parse(response.body)["transfers"].map { |transfer| transfer["id"] }
|
||||
assert_includes transfer_ids, date_matched_transfer.id
|
||||
assert_includes transfer_ids, partial_date_transfer.id
|
||||
assert_not_includes transfer_ids, @transfer.id
|
||||
end
|
||||
|
||||
test "requires authentication" do
|
||||
get api_v1_transfers_url
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "requires read scope" do
|
||||
# ApiKey.create! rejects empty scopes; bypass validation to exercise runtime authorization.
|
||||
api_key_without_read = ApiKey.new(
|
||||
user: @user,
|
||||
name: "No Read Key",
|
||||
scopes: [],
|
||||
source: "mobile",
|
||||
display_key: "no_read_#{SecureRandom.hex(8)}"
|
||||
)
|
||||
api_key_without_read.save!(validate: false)
|
||||
|
||||
get api_v1_transfers_url, headers: api_headers(api_key_without_read)
|
||||
|
||||
assert_response :forbidden
|
||||
ensure
|
||||
api_key_without_read&.destroy
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_transaction(account, amount:, date:, name:)
|
||||
entry = account.entries.create!(
|
||||
date: date,
|
||||
amount: amount,
|
||||
name: name,
|
||||
currency: account.currency,
|
||||
entryable: Transaction.new(kind: "funds_movement")
|
||||
)
|
||||
entry.entryable
|
||||
end
|
||||
|
||||
def api_headers(api_key)
|
||||
{ "X-Api-Key" => api_key.display_key }
|
||||
end
|
||||
end
|
||||
@@ -376,6 +376,93 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "exports transfer decisions and rejected transfers in NDJSON" do
|
||||
destination_account = @family.accounts.create!(
|
||||
name: "Savings Account",
|
||||
accountable: Depository.new,
|
||||
balance: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
transfer_outflow = create_transaction_entry(@account, amount: 100, date: Date.parse("2024-01-15"), name: "Transfer to savings")
|
||||
transfer_inflow = create_transaction_entry(destination_account, amount: -100, date: Date.parse("2024-01-15"), name: "Transfer from checking")
|
||||
transfer = Transfer.create!(
|
||||
outflow_transaction: transfer_outflow.entryable,
|
||||
inflow_transaction: transfer_inflow.entryable,
|
||||
status: "confirmed",
|
||||
notes: "Confirmed by user"
|
||||
)
|
||||
|
||||
rejected_outflow = create_transaction_entry(@account, amount: 25, date: Date.parse("2024-01-20"), name: "Candidate outflow")
|
||||
rejected_inflow = create_transaction_entry(destination_account, amount: -25, date: Date.parse("2024-01-20"), name: "Candidate inflow")
|
||||
rejected_transfer = RejectedTransfer.create!(
|
||||
outflow_transaction: rejected_outflow.entryable,
|
||||
inflow_transaction: rejected_inflow.entryable
|
||||
)
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
ndjson_records = zip.read("all.ndjson").split("\n").map { |line| JSON.parse(line) }
|
||||
|
||||
transfer_data = ndjson_records.find { |record| record["type"] == "Transfer" && record.dig("data", "id") == transfer.id }
|
||||
assert transfer_data
|
||||
assert_equal transfer_inflow.entryable.id, transfer_data["data"]["inflow_transaction_id"]
|
||||
assert_equal transfer_outflow.entryable.id, transfer_data["data"]["outflow_transaction_id"]
|
||||
assert_equal "confirmed", transfer_data["data"]["status"]
|
||||
assert_equal "Confirmed by user", transfer_data["data"]["notes"]
|
||||
|
||||
rejected_transfer_data = ndjson_records.find { |record| record["type"] == "RejectedTransfer" && record.dig("data", "id") == rejected_transfer.id }
|
||||
assert rejected_transfer_data
|
||||
assert_equal rejected_inflow.entryable.id, rejected_transfer_data["data"]["inflow_transaction_id"]
|
||||
assert_equal rejected_outflow.entryable.id, rejected_transfer_data["data"]["outflow_transaction_id"]
|
||||
|
||||
# Transfer decisions must follow Transaction records so import can remap both sides.
|
||||
transaction_indices = ndjson_records.each_index.select { |index| ndjson_records[index]["type"] == "Transaction" }
|
||||
transfer_index = ndjson_records.index(transfer_data)
|
||||
rejected_transfer_index = ndjson_records.index(rejected_transfer_data)
|
||||
|
||||
assert_operator transaction_indices.max, :<, transfer_index
|
||||
assert_operator transaction_indices.max, :<, rejected_transfer_index
|
||||
end
|
||||
end
|
||||
|
||||
test "does not export transfer decisions for split parent transactions" do
|
||||
destination_account = @family.accounts.create!(
|
||||
name: "Split Transfer Savings",
|
||||
accountable: Depository.new,
|
||||
balance: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
split_parent_outflow = create_transaction_entry(@account, amount: 60, date: Date.parse("2024-01-25"), name: "Split transfer parent")
|
||||
split_parent_outflow.split!([
|
||||
{ name: "Split transfer child", amount: 60, category_id: @category.id }
|
||||
])
|
||||
transfer_inflow = create_transaction_entry(destination_account, amount: -60, date: Date.parse("2024-01-25"), name: "Split transfer inflow")
|
||||
transfer = Transfer.create!(
|
||||
outflow_transaction: split_parent_outflow.entryable,
|
||||
inflow_transaction: transfer_inflow.entryable,
|
||||
status: "confirmed"
|
||||
)
|
||||
|
||||
zip_data = @exporter.generate_export
|
||||
|
||||
Zip::File.open_buffer(zip_data) do |zip|
|
||||
ndjson_records = zip.read("all.ndjson").split("\n").map { |line| JSON.parse(line) }
|
||||
|
||||
transaction_ids = ndjson_records
|
||||
.select { |record| record["type"] == "Transaction" }
|
||||
.map { |record| record.dig("data", "id") }
|
||||
transfer_ids = ndjson_records
|
||||
.select { |record| record["type"] == "Transfer" }
|
||||
.map { |record| record.dig("data", "id") }
|
||||
|
||||
assert_not_includes transaction_ids, split_parent_outflow.entryable.id
|
||||
assert_not_includes transfer_ids, transfer.id
|
||||
end
|
||||
end
|
||||
|
||||
test "exports balance history in NDJSON for backup verification" do
|
||||
balance = @account.balances.create!(
|
||||
date: Date.parse("2024-01-15"),
|
||||
@@ -500,4 +587,16 @@ class Family::DataExporterTest < ActiveSupport::TestCase
|
||||
refute ndjson_content.include?(other_rule.name)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_transaction_entry(account, amount:, date:, name:)
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
amount: amount,
|
||||
name: name,
|
||||
currency: account.currency,
|
||||
entryable: Transaction.new(kind: "funds_movement")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -888,6 +888,199 @@ class Family::DataImporterTest < ActiveSupport::TestCase
|
||||
assert_equal "reconciliation", valuation.kind
|
||||
end
|
||||
|
||||
test "imports transfer decisions and rejected transfers with remapped transactions" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "checking",
|
||||
name: "Checking",
|
||||
balance: "1000",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "savings",
|
||||
name: "Savings",
|
||||
balance: "2500",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "transfer-outflow",
|
||||
account_id: "checking",
|
||||
date: "2024-01-15",
|
||||
amount: "100.00",
|
||||
name: "Transfer to savings",
|
||||
currency: "USD",
|
||||
kind: "funds_movement"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "transfer-inflow",
|
||||
account_id: "savings",
|
||||
date: "2024-01-15",
|
||||
amount: "-100.00",
|
||||
name: "Transfer from checking",
|
||||
currency: "USD",
|
||||
kind: "funds_movement"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transfer",
|
||||
data: {
|
||||
id: "transfer-1",
|
||||
inflow_transaction_id: "transfer-inflow",
|
||||
outflow_transaction_id: "transfer-outflow",
|
||||
status: "confirmed",
|
||||
notes: "Confirmed by user"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "rejected-outflow",
|
||||
account_id: "checking",
|
||||
date: "2024-01-20",
|
||||
amount: "25.00",
|
||||
name: "Candidate outflow",
|
||||
currency: "USD",
|
||||
kind: "standard"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "rejected-inflow",
|
||||
account_id: "savings",
|
||||
date: "2024-01-20",
|
||||
amount: "-25.00",
|
||||
name: "Candidate inflow",
|
||||
currency: "USD",
|
||||
kind: "standard"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "RejectedTransfer",
|
||||
data: {
|
||||
id: "rejected-transfer-1",
|
||||
inflow_transaction_id: "rejected-inflow",
|
||||
outflow_transaction_id: "rejected-outflow"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
|
||||
transfer = Transfer.find_by!(notes: "Confirmed by user")
|
||||
assert_not_nil transfer
|
||||
assert_equal "confirmed", transfer.status
|
||||
assert_equal "Confirmed by user", transfer.notes
|
||||
assert_equal "Transfer from checking", transfer.inflow_transaction.entry.name
|
||||
assert_equal "Transfer to savings", transfer.outflow_transaction.entry.name
|
||||
|
||||
rejected_transfer = RejectedTransfer
|
||||
.joins(inflow_transaction: :entry)
|
||||
.find_by!(entries: { name: "Candidate inflow" })
|
||||
assert_not_nil rejected_transfer
|
||||
assert_equal "Candidate inflow", rejected_transfer.inflow_transaction.entry.name
|
||||
assert_equal "Candidate outflow", rejected_transfer.outflow_transaction.entry.name
|
||||
end
|
||||
|
||||
test "imports duplicate transfer decisions idempotently with unknown status fallback" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "checking",
|
||||
name: "Checking",
|
||||
balance: "1000",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Account",
|
||||
data: {
|
||||
id: "savings",
|
||||
name: "Savings",
|
||||
balance: "2500",
|
||||
currency: "USD",
|
||||
accountable_type: "Depository"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "transfer-outflow",
|
||||
account_id: "checking",
|
||||
date: "2024-01-15",
|
||||
amount: "100.00",
|
||||
name: "Transfer to savings",
|
||||
currency: "USD",
|
||||
kind: "funds_movement"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transaction",
|
||||
data: {
|
||||
id: "transfer-inflow",
|
||||
account_id: "savings",
|
||||
date: "2024-01-15",
|
||||
amount: "-100.00",
|
||||
name: "Transfer from checking",
|
||||
currency: "USD",
|
||||
kind: "funds_movement"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transfer",
|
||||
data: {
|
||||
id: "transfer-1",
|
||||
inflow_transaction_id: "transfer-inflow",
|
||||
outflow_transaction_id: "transfer-outflow",
|
||||
status: "settled"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Transfer",
|
||||
data: {
|
||||
id: "transfer-1-duplicate",
|
||||
inflow_transaction_id: "transfer-inflow",
|
||||
outflow_transaction_id: "transfer-outflow",
|
||||
status: "settled"
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
fallback_logs = []
|
||||
|
||||
Rails.logger.stubs(:debug).with do |*args|
|
||||
message = args.first
|
||||
fallback_logs << message if message.to_s.include?("Unknown transfer status")
|
||||
true
|
||||
end
|
||||
|
||||
assert_difference("Transfer.count", 1) do
|
||||
Family::DataImporter.new(@family, ndjson).import!
|
||||
end
|
||||
|
||||
assert_equal [ 'Unknown transfer status "settled"; defaulting to pending' ], fallback_logs
|
||||
|
||||
imported_transfer = Transfer
|
||||
.joins(inflow_transaction: :entry)
|
||||
.find_by!(entries: { name: "Transfer from checking" })
|
||||
assert_equal "pending", imported_transfer.status
|
||||
end
|
||||
|
||||
test "imports budgets" do
|
||||
ndjson = build_ndjson([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user