From 8abecf8a8da687589ec6336e0c81c42ac354d8f6 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Fri, 8 May 2026 15:03:57 -0600 Subject: [PATCH] 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 --- app/controllers/api/v1/base_controller.rb | 4 + .../api/v1/rejected_transfers_controller.rb | 40 ++ .../api/v1/transfers_controller.rb | 40 ++ .../api/v1/transfer_decision_filtering.rb | 92 +++++ app/helpers/api/v1/money_helper.rb | 7 + app/models/family/data_exporter.rb | 56 ++- app/models/family/data_importer.rb | 46 ++- .../_rejected_transfer.json.jbuilder | 14 + .../v1/rejected_transfers/index.json.jbuilder | 12 + .../v1/rejected_transfers/show.json.jbuilder | 3 + .../transfers/_transaction_side.json.jbuilder | 18 + .../api/v1/transfers/_transfer.json.jbuilder | 21 + .../api/v1/transfers/index.json.jbuilder | 12 + app/views/api/v1/transfers/show.json.jbuilder | 3 + config/routes.rb | 2 + docs/api/openapi.yaml | 365 ++++++++++++++++++ .../api/v1/rejected_transfers_spec.rb | 165 ++++++++ spec/requests/api/v1/transfers_spec.rb | 170 ++++++++ spec/swagger_helper.rb | 74 ++++ .../v1/rejected_transfers_controller_test.rb | 182 +++++++++ .../api/v1/transfers_controller_test.rb | 203 ++++++++++ test/models/family/data_exporter_test.rb | 99 +++++ test/models/family/data_importer_test.rb | 193 +++++++++ 23 files changed, 1817 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/rejected_transfers_controller.rb create mode 100644 app/controllers/api/v1/transfers_controller.rb create mode 100644 app/controllers/concerns/api/v1/transfer_decision_filtering.rb create mode 100644 app/helpers/api/v1/money_helper.rb create mode 100644 app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder create mode 100644 app/views/api/v1/rejected_transfers/index.json.jbuilder create mode 100644 app/views/api/v1/rejected_transfers/show.json.jbuilder create mode 100644 app/views/api/v1/transfers/_transaction_side.json.jbuilder create mode 100644 app/views/api/v1/transfers/_transfer.json.jbuilder create mode 100644 app/views/api/v1/transfers/index.json.jbuilder create mode 100644 app/views/api/v1/transfers/show.json.jbuilder create mode 100644 spec/requests/api/v1/rejected_transfers_spec.rb create mode 100644 spec/requests/api/v1/transfers_spec.rb create mode 100644 test/controllers/api/v1/rejected_transfers_controller_test.rb create mode 100644 test/controllers/api/v1/transfers_controller_test.rb diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 3c2b21a71..b2e8bc59c 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -210,6 +210,10 @@ class Api::V1::BaseController < ApplicationController true end + def ensure_read_scope + authorize_scope!(:read) + end + # Consistent JSON response method def render_json(data, status: :ok) render json: data, status: status diff --git a/app/controllers/api/v1/rejected_transfers_controller.rb b/app/controllers/api/v1/rejected_transfers_controller.rb new file mode 100644 index 000000000..e341341bd --- /dev/null +++ b/app/controllers/api/v1/rejected_transfers_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::RejectedTransfersController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::TransferDecisionFiltering + + before_action :ensure_read_scope + before_action :set_rejected_transfer, only: :show + + def index + rejected_transfers_query = apply_transfer_decision_filters(rejected_transfers_scope).order(created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @rejected_transfers = pagy( + rejected_transfers_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::TransferDecisionFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_rejected_transfer + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @rejected_transfer = rejected_transfers_scope.find(params[:id]) + end + + def rejected_transfers_scope + transfer_decision_scope(RejectedTransfer) + end +end diff --git a/app/controllers/api/v1/transfers_controller.rb b/app/controllers/api/v1/transfers_controller.rb new file mode 100644 index 000000000..bfbfe9c68 --- /dev/null +++ b/app/controllers/api/v1/transfers_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Api::V1::TransfersController < Api::V1::BaseController + include Pagy::Backend + include Api::V1::TransferDecisionFiltering + + before_action :ensure_read_scope + before_action :set_transfer, only: :show + + def index + transfers_query = apply_transfer_decision_filters(transfers_scope, status_model: Transfer).order(created_at: :desc) + @per_page = safe_per_page_param + + @pagy, @transfers = pagy( + transfers_query, + page: safe_page_param, + limit: @per_page + ) + + render :index + rescue Api::V1::TransferDecisionFiltering::InvalidFilterError => e + render_validation_error(e.message) + end + + def show + render :show + end + + private + + def set_transfer + raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id]) + + @transfer = transfers_scope.find(params[:id]) + end + + def transfers_scope + transfer_decision_scope(Transfer) + end +end diff --git a/app/controllers/concerns/api/v1/transfer_decision_filtering.rb b/app/controllers/concerns/api/v1/transfer_decision_filtering.rb new file mode 100644 index 000000000..fa2d0ac5c --- /dev/null +++ b/app/controllers/concerns/api/v1/transfer_decision_filtering.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Api::V1::TransferDecisionFiltering + extend ActiveSupport::Concern + + InvalidFilterError = Class.new(StandardError) + + private + + def transfer_decision_scope(model_class) + model_class + .where( + inflow_transaction_id: accessible_transaction_ids, + outflow_transaction_id: accessible_transaction_ids + ) + .includes( + inflow_transaction: { entry: :account }, + outflow_transaction: { entry: :account } + ) + end + + def apply_transfer_decision_filters(query, status_model: nil) + query = apply_transfer_status_filter(query, status_model) if status_model + query = apply_transfer_account_filter(query) if params[:account_id].present? + query = apply_transfer_date_filter(query) if params[:start_date].present? || params[:end_date].present? + query + end + + def accessible_transaction_ids + accessible_transactions.select(:id) + end + + def accessible_transactions + Transaction + .joins(:entry) + .where(entries: { account_id: accessible_account_ids }) + end + + def accessible_account_ids + @accessible_account_ids ||= Current.family.accounts.accessible_by(Current.user).select(:id) + end + + def apply_transfer_status_filter(query, status_model) + return query unless params[:status].present? + + unless status_model.statuses.key?(params[:status]) + invalid_filter!("status must be one of: #{status_model.statuses.keys.join(", ")}") + end + + query.where(status: params[:status]) + end + + def apply_transfer_account_filter(query) + invalid_filter!("account_id must be a valid UUID") unless valid_uuid?(params[:account_id]) + + account_transaction_ids = accessible_transaction_ids_for_account(params[:account_id]) + query + .where(inflow_transaction_id: account_transaction_ids) + .or(query.where(outflow_transaction_id: account_transaction_ids)) + end + + def apply_transfer_date_filter(query) + date_transaction_ids = transfer_date_transaction_ids + query + .where(inflow_transaction_id: date_transaction_ids) + .or(query.where(outflow_transaction_id: date_transaction_ids)) + end + + def accessible_transaction_ids_for_account(account_id) + Transaction + .joins(:entry) + .where(entries: { account_id: accessible_account_ids.where(id: account_id) }) + .select(:id) + end + + def transfer_date_transaction_ids + query = accessible_transactions + query = query.where("entries.date >= ?", parse_date_param(:start_date)) if params[:start_date].present? + query = query.where("entries.date <= ?", parse_date_param(:end_date)) if params[:end_date].present? + query.select(:id) + end + + def parse_date_param(key) + Date.iso8601(params[key].to_s) + rescue ArgumentError + invalid_filter!("#{key} must be an ISO 8601 date") + end + + def invalid_filter!(message) + raise InvalidFilterError, message + end +end diff --git a/app/helpers/api/v1/money_helper.rb b/app/helpers/api/v1/money_helper.rb new file mode 100644 index 000000000..2556a1941 --- /dev/null +++ b/app/helpers/api/v1/money_helper.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Api::V1::MoneyHelper + def money_to_minor_units(money) + (money.amount * money.currency.minor_unit_conversion).round(0).to_i if money + end +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index f5b9216b3..1c3446f2d 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -66,9 +66,8 @@ class Family::DataExporter # Only export transactions from accounts belonging to this family # Exclude split parents (export children instead) - @family.transactions + exportable_transactions .includes(:category, :tags, entry: :account) - .merge(Entry.excluding_split_parents) .find_each do |transaction| csv << [ transaction.entry.date.iso8601, @@ -220,7 +219,7 @@ class Family::DataExporter end # Export transactions with full data (exclude split parents, export children instead) - @family.transactions.includes(:category, :merchant, :tags, entry: :account).merge(Entry.excluding_split_parents).find_each do |transaction| + exportable_transactions.includes(:category, :merchant, :tags, entry: :account).find_each do |transaction| lines << { type: "Transaction", data: { @@ -243,6 +242,35 @@ class Family::DataExporter }.to_json end + # Export transfer decisions after transactions so import can remap both sides. + family_transfers.find_each do |transfer| + lines << { + type: "Transfer", + data: { + id: transfer.id, + inflow_transaction_id: transfer.inflow_transaction_id, + outflow_transaction_id: transfer.outflow_transaction_id, + status: transfer.status, + notes: transfer.notes, + created_at: transfer.created_at, + updated_at: transfer.updated_at + } + }.to_json + end + + family_rejected_transfers.find_each do |rejected_transfer| + lines << { + type: "RejectedTransfer", + data: { + id: rejected_transfer.id, + inflow_transaction_id: rejected_transfer.inflow_transaction_id, + outflow_transaction_id: rejected_transfer.outflow_transaction_id, + created_at: rejected_transfer.created_at, + updated_at: rejected_transfer.updated_at + } + }.to_json + end + # Export trades with full data @family.trades.includes(:security, entry: :account).find_each do |trade| lines << { @@ -342,6 +370,28 @@ class Family::DataExporter lines.join("\n") end + def exportable_transactions + @family.transactions.merge(Entry.excluding_split_parents) + end + + def family_transaction_ids + @family_transaction_ids ||= exportable_transactions.select(:id) + end + + def family_transfers + Transfer.where( + inflow_transaction_id: family_transaction_ids, + outflow_transaction_id: family_transaction_ids + ) + end + + def family_rejected_transfers + RejectedTransfer.where( + inflow_transaction_id: family_transaction_ids, + outflow_transaction_id: family_transaction_ids + ) + end + def serialize_recurring_transaction_for_export(recurring_transaction) { id: recurring_transaction.id, diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb index f1b119576..171caf620 100644 --- a/app/models/family/data_importer.rb +++ b/app/models/family/data_importer.rb @@ -1,7 +1,7 @@ require "set" class Family::DataImporter - SUPPORTED_TYPES = %w[Account Category Tag Merchant RecurringTransaction Transaction Trade Holding Valuation Budget BudgetCategory Rule].freeze + SUPPORTED_TYPES = %w[Account Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze ACCOUNTABLE_TYPES = Accountable::TYPES.freeze def initialize(family, ndjson_content) @@ -13,6 +13,7 @@ class Family::DataImporter tags: {}, merchants: {}, recurring_transactions: {}, + transactions: {}, budgets: {}, securities: {} } @@ -34,6 +35,8 @@ class Family::DataImporter import_merchants(records["Merchant"] || []) import_recurring_transactions(records["RecurringTransaction"] || []) import_transactions(records["Transaction"] || []) + import_transfers(records["Transfer"] || []) + import_rejected_transfers(records["RejectedTransfer"] || []) import_trades(records["Trade"] || []) import_holdings(records["Holding"] || []) import_valuations(records["Valuation"] || []) @@ -256,6 +259,7 @@ class Family::DataImporter def import_transactions(records) records.each do |record| data = record["data"] + old_id = data["id"] # Map account ID new_account_id = @id_mappings[:accounts][data["account_id"]] @@ -306,9 +310,49 @@ class Family::DataImporter end @created_entries << entry + @id_mappings[:transactions][old_id] = transaction.id end end + def import_transfers(records) + records.each do |record| + data = record["data"] + inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] + outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + next unless inflow_transaction_id && outflow_transaction_id + + Transfer.find_or_create_by!( + inflow_transaction_id: inflow_transaction_id, + outflow_transaction_id: outflow_transaction_id + ) do |transfer| + transfer.status = transfer_status_for(data["status"]) + transfer.notes = data["notes"] + end + end + end + + def import_rejected_transfers(records) + records.each do |record| + data = record["data"] + inflow_transaction_id = @id_mappings[:transactions][data["inflow_transaction_id"]] + outflow_transaction_id = @id_mappings[:transactions][data["outflow_transaction_id"]] + next unless inflow_transaction_id && outflow_transaction_id + + RejectedTransfer.find_or_create_by!( + inflow_transaction_id: inflow_transaction_id, + outflow_transaction_id: outflow_transaction_id + ) + end + end + + def transfer_status_for(status) + status = status.to_s + return status if Transfer.statuses.key?(status) + + Rails.logger.debug("Unknown transfer status #{status.inspect}; defaulting to pending") if status.present? + "pending" + end + def import_trades(records) records.each do |record| data = record["data"] diff --git a/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder b/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder new file mode 100644 index 000000000..d8efa4425 --- /dev/null +++ b/app/views/api/v1/rejected_transfers/_rejected_transfer.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.id rejected_transfer.id + +json.inflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: rejected_transfer.inflow_transaction +end + +json.outflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: rejected_transfer.outflow_transaction +end + +json.created_at rejected_transfer.created_at.iso8601 +json.updated_at rejected_transfer.updated_at.iso8601 diff --git a/app/views/api/v1/rejected_transfers/index.json.jbuilder b/app/views/api/v1/rejected_transfers/index.json.jbuilder new file mode 100644 index 000000000..08c070f9b --- /dev/null +++ b/app/views/api/v1/rejected_transfers/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.rejected_transfers @rejected_transfers do |rejected_transfer| + json.partial! "rejected_transfer", rejected_transfer: rejected_transfer +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/rejected_transfers/show.json.jbuilder b/app/views/api/v1/rejected_transfers/show.json.jbuilder new file mode 100644 index 000000000..60edba7cf --- /dev/null +++ b/app/views/api/v1/rejected_transfers/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "rejected_transfer", rejected_transfer: @rejected_transfer diff --git a/app/views/api/v1/transfers/_transaction_side.json.jbuilder b/app/views/api/v1/transfers/_transaction_side.json.jbuilder new file mode 100644 index 000000000..99b3099b3 --- /dev/null +++ b/app/views/api/v1/transfers/_transaction_side.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +entry = transaction.entry + +json.id transaction.id +json.entry_id entry.id +json.date entry.date +json.amount entry.amount_money.format +json.amount_cents money_to_minor_units(entry.amount_money) +json.currency entry.currency +json.name entry.name +json.kind transaction.kind + +json.account do + json.id entry.account.id + json.name entry.account.name + json.account_type entry.account.accountable_type.underscore +end diff --git a/app/views/api/v1/transfers/_transfer.json.jbuilder b/app/views/api/v1/transfers/_transfer.json.jbuilder new file mode 100644 index 000000000..1fb27815f --- /dev/null +++ b/app/views/api/v1/transfers/_transfer.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +json.id transfer.id +json.status transfer.status +json.date transfer.date +json.amount transfer.amount_abs.format +json.amount_cents money_to_minor_units(transfer.amount_abs) +json.currency transfer.inflow_transaction.entry.currency +json.transfer_type transfer.transfer_type +json.notes transfer.notes + +json.inflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: transfer.inflow_transaction +end + +json.outflow_transaction do + json.partial! "api/v1/transfers/transaction_side", transaction: transfer.outflow_transaction +end + +json.created_at transfer.created_at.iso8601 +json.updated_at transfer.updated_at.iso8601 diff --git a/app/views/api/v1/transfers/index.json.jbuilder b/app/views/api/v1/transfers/index.json.jbuilder new file mode 100644 index 000000000..fdb49b15d --- /dev/null +++ b/app/views/api/v1/transfers/index.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +json.transfers @transfers do |transfer| + json.partial! "transfer", transfer: transfer +end + +json.pagination do + json.page @pagy.page + json.per_page @per_page + json.total_count @pagy.count + json.total_pages @pagy.pages +end diff --git a/app/views/api/v1/transfers/show.json.jbuilder b/app/views/api/v1/transfers/show.json.jbuilder new file mode 100644 index 000000000..d5c0710f3 --- /dev/null +++ b/app/views/api/v1/transfers/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "transfer", transfer: @transfer diff --git a/config/routes.rb b/config/routes.rb index b9735a757..fcda7daf5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -439,6 +439,8 @@ Rails.application.routes.draw do resources :transactions, only: [ :index, :show, :create, :update, :destroy ] resources :trades, only: [ :index, :show, :create, :update, :destroy ] resources :holdings, only: [ :index, :show ] + resources :transfers, only: [ :index, :show ] + resources :rejected_transfers, only: [ :index, :show ] resources :valuations, only: [ :index, :create, :update, :show ] resources :recurring_transactions, only: [ :index, :show, :create, :update, :destroy ] resources :family_exports, only: [ :index, :show, :create ] do diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 634032ac1..9b2f867d9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1470,6 +1470,152 @@ components: "$ref": "#/components/schemas/Transaction" pagination: "$ref": "#/components/schemas/Pagination" + TransferTransactionSide: + type: object + required: + - id + - entry_id + - date + - amount + - amount_cents + - currency + - name + - kind + - account + properties: + id: + type: string + format: uuid + entry_id: + type: string + format: uuid + date: + type: string + format: date + amount: + type: string + amount_cents: + type: integer + description: Signed amount in currency minor units + currency: + type: string + name: + type: string + kind: + type: string + account: + type: object + required: + - id + - name + - account_type + properties: + id: + type: string + format: uuid + name: + type: string + account_type: + type: string + nullable: true + TransferDecision: + type: object + required: + - id + - status + - date + - amount + - amount_cents + - currency + - transfer_type + - inflow_transaction + - outflow_transaction + - created_at + - updated_at + properties: + id: + type: string + format: uuid + status: + type: string + enum: + - pending + - confirmed + date: + type: string + format: date + amount: + type: string + amount_cents: + type: integer + description: Absolute transfer amount in currency minor units + currency: + type: string + transfer_type: + type: string + enum: + - transfer + - liability_payment + - loan_payment + notes: + type: string + nullable: true + inflow_transaction: + "$ref": "#/components/schemas/TransferTransactionSide" + outflow_transaction: + "$ref": "#/components/schemas/TransferTransactionSide" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + TransferDecisionCollection: + type: object + required: + - transfers + - pagination + properties: + transfers: + type: array + items: + "$ref": "#/components/schemas/TransferDecision" + pagination: + "$ref": "#/components/schemas/Pagination" + RejectedTransfer: + type: object + required: + - id + - inflow_transaction + - outflow_transaction + - created_at + - updated_at + properties: + id: + type: string + format: uuid + inflow_transaction: + "$ref": "#/components/schemas/TransferTransactionSide" + outflow_transaction: + "$ref": "#/components/schemas/TransferTransactionSide" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + RejectedTransferCollection: + type: object + required: + - rejected_transfers + - pagination + properties: + rejected_transfers: + type: array + items: + "$ref": "#/components/schemas/RejectedTransfer" + pagination: + "$ref": "#/components/schemas/Pagination" Valuation: type: object required: @@ -4919,6 +5065,111 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rejected_transfers": + get: + summary: List rejected transfers + tags: + - Rejected Transfers + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: account_id + in: query + required: false + schema: + type: string + format: uuid + description: Filter rejected transfers involving this account + - name: start_date + in: query + required: false + schema: + type: string + format: date + description: Filter rejected transfers from this date + - name: end_date + in: query + required: false + schema: + type: string + format: date + description: Filter rejected transfers until this date + responses: + '200': + description: rejected transfers listed + content: + application/json: + schema: + "$ref": "#/components/schemas/RejectedTransferCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/rejected_transfers/{id}": + parameters: + - name: id + in: path + required: true + description: Rejected transfer ID + schema: + type: string + get: + summary: Retrieve a rejected transfer + tags: + - Rejected Transfers + security: + - apiKeyAuth: [] + responses: + '200': + description: rejected transfer retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/RejectedTransfer" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: rejected transfer not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/rule_runs": get: summary: List rule runs @@ -6184,6 +6435,120 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/transfers": + get: + summary: List transfers + tags: + - Transfers + security: + - apiKeyAuth: [] + parameters: + - name: page + in: query + required: false + description: 'Page number (default: 1)' + schema: + type: integer + - name: per_page + in: query + required: false + description: 'Items per page (default: 25, max: 100)' + schema: + type: integer + - name: status + in: query + required: false + schema: + type: string + enum: + - pending + - confirmed + description: Filter by transfer status + - name: account_id + in: query + required: false + schema: + type: string + format: uuid + description: Filter transfers involving this account + - name: start_date + in: query + required: false + schema: + type: string + format: date + description: Filter transfers from this date + - name: end_date + in: query + required: false + schema: + type: string + format: date + description: Filter transfers until this date + responses: + '200': + description: transfers listed + content: + application/json: + schema: + "$ref": "#/components/schemas/TransferDecisionCollection" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: invalid filter + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/transfers/{id}": + parameters: + - name: id + in: path + required: true + description: Transfer ID + schema: + type: string + get: + summary: Retrieve a transfer + tags: + - Transfers + security: + - apiKeyAuth: [] + responses: + '200': + description: transfer retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/TransferDecision" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: transfer not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/users/reset": delete: summary: Reset account diff --git a/spec/requests/api/v1/rejected_transfers_spec.rb b/spec/requests/api/v1/rejected_transfers_spec.rb new file mode 100644 index 000000000..91580a42b --- /dev/null +++ b/spec/requests/api/v1/rejected_transfers_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Rejected Transfers', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + scopes: [], + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + let(:checking) { family.accounts.create!(name: 'Checking', balance: 1000, currency: 'USD', accountable: Depository.create!) } + let(:savings) { family.accounts.create!(name: 'Savings', balance: 2500, currency: 'USD', accountable: Depository.create!) } + let!(:outflow_entry) do + checking.entries.create!( + date: Date.current, + amount: 100, + name: 'Rejected outflow', + currency: 'USD', + entryable: Transaction.new(kind: 'standard') + ) + end + let!(:inflow_entry) do + savings.entries.create!( + date: Date.current, + amount: -100, + name: 'Rejected inflow', + currency: 'USD', + entryable: Transaction.new(kind: 'standard') + ) + end + let!(:rejected_transfer) do + RejectedTransfer.create!( + outflow_transaction: outflow_entry.entryable, + inflow_transaction: inflow_entry.entryable + ) + end + + path '/api/v1/rejected_transfers' do + get 'List rejected transfers' do + tags 'Rejected Transfers' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :account_id, in: :query, required: false, + schema: { type: :string, format: :uuid }, + description: 'Filter rejected transfers involving this account' + parameter name: :start_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter rejected transfers from this date' + parameter name: :end_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter rejected transfers until this date' + + response '200', 'rejected transfers listed' do + schema '$ref' => '#/components/schemas/RejectedTransferCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:account_id) { 'not-a-uuid' } + + run_test! + end + end + end + + path '/api/v1/rejected_transfers/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Rejected transfer ID' + + get 'Retrieve a rejected transfer' do + tags 'Rejected Transfers' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { rejected_transfer.id } + + response '200', 'rejected transfer retrieved' do + schema '$ref' => '#/components/schemas/RejectedTransfer' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'rejected transfer not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/requests/api/v1/transfers_spec.rb b/spec/requests/api/v1/transfers_spec.rb new file mode 100644 index 000000000..47fcdd823 --- /dev/null +++ b/spec/requests/api/v1/transfers_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Transfers', type: :request do + let(:family) do + Family.create!( + name: 'API Family', + currency: 'USD', + locale: 'en', + date_format: '%m-%d-%Y' + ) + end + + let(:user) do + family.users.create!( + email: 'api-user@example.com', + password: 'password123', + password_confirmation: 'password123' + ) + end + + let(:api_key) do + key = ApiKey.generate_secure_key + ApiKey.create!( + user: user, + name: 'API Docs Key', + key: key, + scopes: %w[read_write], + source: 'web' + ) + end + + let(:api_key_without_read_scope) do + key = ApiKey.generate_secure_key + ApiKey.new( + user: user, + name: 'No Read Docs Key', + key: key, + scopes: [], + source: 'mobile' + ).tap { |api_key| api_key.save!(validate: false) } + end + + let(:'X-Api-Key') { api_key.plain_key } + let(:checking) { family.accounts.create!(name: 'Checking', balance: 1000, currency: 'USD', accountable: Depository.create!) } + let(:savings) { family.accounts.create!(name: 'Savings', balance: 2500, currency: 'USD', accountable: Depository.create!) } + let!(:outflow_entry) do + checking.entries.create!( + date: Date.current, + amount: 100, + name: 'Transfer to savings', + currency: 'USD', + entryable: Transaction.new(kind: 'funds_movement') + ) + end + let!(:inflow_entry) do + savings.entries.create!( + date: Date.current, + amount: -100, + name: 'Transfer from checking', + currency: 'USD', + entryable: Transaction.new(kind: 'funds_movement') + ) + end + let!(:transfer) do + Transfer.create!( + outflow_transaction: outflow_entry.entryable, + inflow_transaction: inflow_entry.entryable, + status: 'confirmed', + notes: 'Confirmed by user' + ) + end + + path '/api/v1/transfers' do + get 'List transfers' do + tags 'Transfers' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + parameter name: :page, in: :query, type: :integer, required: false, + description: 'Page number (default: 1)' + parameter name: :per_page, in: :query, type: :integer, required: false, + description: 'Items per page (default: 25, max: 100)' + parameter name: :status, in: :query, required: false, + schema: { type: :string, enum: %w[pending confirmed] }, + description: 'Filter by transfer status' + parameter name: :account_id, in: :query, required: false, + schema: { type: :string, format: :uuid }, + description: 'Filter transfers involving this account' + parameter name: :start_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter transfers from this date' + parameter name: :end_date, in: :query, required: false, + schema: { type: :string, format: :date }, + description: 'Filter transfers until this date' + + response '200', 'transfers listed' do + schema '$ref' => '#/components/schemas/TransferDecisionCollection' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '422', 'invalid filter' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:status) { 'settled' } + + run_test! + end + end + end + + path '/api/v1/transfers/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Transfer ID' + + get 'Retrieve a transfer' do + tags 'Transfers' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { transfer.id } + + response '200', 'transfer retrieved' do + schema '$ref' => '#/components/schemas/TransferDecision' + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_read_scope.plain_key } + + run_test! + end + + response '404', 'transfer not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end +end diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb index 4df5ff0c3..d8260c27e 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -812,6 +812,80 @@ RSpec.configure do |config| pagination: { '$ref' => '#/components/schemas/Pagination' } } }, + TransferTransactionSide: { + type: :object, + required: %w[id entry_id date amount amount_cents currency name kind account], + properties: { + id: { type: :string, format: :uuid }, + entry_id: { type: :string, format: :uuid }, + date: { type: :string, format: :date }, + amount: { type: :string }, + amount_cents: { type: :integer, description: 'Signed amount in currency minor units' }, + currency: { type: :string }, + name: { type: :string }, + kind: { type: :string }, + account: { + type: :object, + required: %w[id name account_type], + properties: { + id: { type: :string, format: :uuid }, + name: { type: :string }, + account_type: { type: :string, nullable: true } + } + } + } + }, + TransferDecision: { + type: :object, + required: %w[id status date amount amount_cents currency transfer_type inflow_transaction outflow_transaction created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + status: { type: :string, enum: %w[pending confirmed] }, + date: { type: :string, format: :date }, + amount: { type: :string }, + amount_cents: { type: :integer, description: 'Absolute transfer amount in currency minor units' }, + currency: { type: :string }, + transfer_type: { type: :string, enum: %w[transfer liability_payment loan_payment] }, + notes: { type: :string, nullable: true }, + inflow_transaction: { '$ref' => '#/components/schemas/TransferTransactionSide' }, + outflow_transaction: { '$ref' => '#/components/schemas/TransferTransactionSide' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + TransferDecisionCollection: { + type: :object, + required: %w[transfers pagination], + properties: { + transfers: { + type: :array, + items: { '$ref' => '#/components/schemas/TransferDecision' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, + RejectedTransfer: { + type: :object, + required: %w[id inflow_transaction outflow_transaction created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + inflow_transaction: { '$ref' => '#/components/schemas/TransferTransactionSide' }, + outflow_transaction: { '$ref' => '#/components/schemas/TransferTransactionSide' }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + RejectedTransferCollection: { + type: :object, + required: %w[rejected_transfers pagination], + properties: { + rejected_transfers: { + type: :array, + items: { '$ref' => '#/components/schemas/RejectedTransfer' } + }, + pagination: { '$ref' => '#/components/schemas/Pagination' } + } + }, Valuation: { type: :object, required: %w[id date amount currency kind account created_at updated_at], diff --git a/test/controllers/api/v1/rejected_transfers_controller_test.rb b/test/controllers/api/v1/rejected_transfers_controller_test.rb new file mode 100644 index 000000000..ad64de5ea --- /dev/null +++ b/test/controllers/api/v1/rejected_transfers_controller_test.rb @@ -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 diff --git a/test/controllers/api/v1/transfers_controller_test.rb b/test/controllers/api/v1/transfers_controller_test.rb new file mode 100644 index 000000000..e74dcfbda --- /dev/null +++ b/test/controllers/api/v1/transfers_controller_test.rb @@ -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 diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 19d2282f8..b1f2e01eb 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -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 diff --git a/test/models/family/data_importer_test.rb b/test/models/family/data_importer_test.rb index 1284a6630..b216acc9d 100644 --- a/test/models/family/data_importer_test.rb +++ b/test/models/family/data_importer_test.rb @@ -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([ {