diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 78324f5a9..12f980fa8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5,7 +5,7 @@ info: version: v1 description: OpenAPI documentation generated from executable request specs. servers: -- url: https://api.sure.app +- url: https://app.sure.am description: Production - url: http://localhost:3000 description: Local development @@ -416,6 +416,185 @@ components: properties: message: type: string + ImportConfiguration: + type: object + properties: + date_col_label: + type: string + nullable: true + amount_col_label: + type: string + nullable: true + name_col_label: + type: string + nullable: true + category_col_label: + type: string + nullable: true + tags_col_label: + type: string + nullable: true + notes_col_label: + type: string + nullable: true + account_col_label: + type: string + nullable: true + date_format: + type: string + nullable: true + number_format: + type: string + nullable: true + signage_convention: + type: string + nullable: true + ImportStats: + type: object + properties: + rows_count: + type: integer + minimum: 0 + valid_rows_count: + type: integer + minimum: 0 + nullable: true + ImportSummary: + type: object + required: + - id + - type + - status + - created_at + - updated_at + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - TransactionImport + - TradeImport + - AccountImport + - MintImport + - CategoryImport + - RuleImport + status: + type: string + enum: + - pending + - complete + - importing + - reverting + - revert_failed + - failed + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + account_id: + type: string + format: uuid + nullable: true + rows_count: + type: integer + minimum: 0 + error: + type: string + nullable: true + ImportDetail: + type: object + required: + - id + - type + - status + - created_at + - updated_at + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - TransactionImport + - TradeImport + - AccountImport + - MintImport + - CategoryImport + - RuleImport + status: + type: string + enum: + - pending + - complete + - importing + - reverting + - revert_failed + - failed + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + account_id: + type: string + format: uuid + nullable: true + error: + type: string + nullable: true + configuration: + "$ref": "#/components/schemas/ImportConfiguration" + stats: + "$ref": "#/components/schemas/ImportStats" + ImportCollection: + type: object + required: + - data + - meta + properties: + data: + type: array + items: + "$ref": "#/components/schemas/ImportSummary" + meta: + type: object + required: + - current_page + - total_pages + - total_count + - per_page + properties: + current_page: + type: integer + minimum: 1 + next_page: + type: integer + nullable: true + prev_page: + type: integer + nullable: true + total_pages: + type: integer + minimum: 0 + total_count: + type: integer + minimum: 0 + per_page: + type: integer + minimum: 1 + ImportResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/ImportDetail" paths: "/api/v1/categories": get: @@ -748,6 +927,197 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/imports": + get: + summary: List imports + description: List all imports for the user's family with pagination and filtering. + tags: + - Imports + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with read scope + - 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 + description: Filter by status + schema: + type: string + enum: + - pending + - complete + - importing + - reverting + - revert_failed + - failed + - name: type + in: query + required: false + description: Filter by import type + schema: + type: string + enum: + - TransactionImport + - TradeImport + - AccountImport + - MintImport + - CategoryImport + - RuleImport + responses: + '200': + description: imports filtered by type + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportCollection" + post: + summary: Create import + description: Create a new import from raw CSV content. + tags: + - Imports + security: + - bearerAuth: [] + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with write scope + responses: + '201': + description: import created + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportResponse" + '422': + description: validation error - file too large + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + raw_file_content: + type: string + description: The raw CSV content as a string + type: + type: string + enum: + - TransactionImport + - TradeImport + - AccountImport + - MintImport + - CategoryImport + - RuleImport + description: Import type (defaults to TransactionImport) + account_id: + type: string + format: uuid + description: Account ID to import into + publish: + type: string + description: Set to "true" to automatically queue for processing + if configuration is valid + date_col_label: + type: string + description: Header name for the date column + amount_col_label: + type: string + description: Header name for the amount column + name_col_label: + type: string + description: Header name for the transaction name column + category_col_label: + type: string + description: Header name for the category column + tags_col_label: + type: string + description: Header name for the tags column + notes_col_label: + type: string + description: Header name for the notes column + date_format: + type: string + description: Date format pattern (e.g., "%m/%d/%Y") + number_format: + type: string + enum: + - '1,234.56' + - 1.234,56 + - 1 234,56 + - '1,234' + description: Number format for parsing amounts + signage_convention: + type: string + enum: + - inflows_positive + - inflows_negative + description: How to interpret positive/negative amounts + col_sep: + type: string + enum: + - "," + - ";" + description: Column separator + required: true + "/api/v1/imports/{id}": + parameters: + - name: Authorization + in: header + required: true + schema: + type: string + description: Bearer token with read scope + - name: id + in: path + required: true + description: Import ID + schema: + type: string + get: + summary: Retrieve an import + description: Retrieve detailed information about a specific import, including + configuration and row statistics. + tags: + - Imports + security: + - bearerAuth: [] + responses: + '200': + description: import retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportResponse" + '404': + description: import not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/transactions": get: summary: List transactions diff --git a/spec/requests/api/v1/imports_spec.rb b/spec/requests/api/v1/imports_spec.rb new file mode 100644 index 000000000..c5d01d499 --- /dev/null +++ b/spec/requests/api/v1/imports_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Imports', 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(:oauth_application) do + Doorkeeper::Application.create!( + name: 'API Docs', + redirect_uri: 'https://example.com/callback', + scopes: 'read read_write' + ) + end + + let(:access_token) do + Doorkeeper::AccessToken.create!( + application: oauth_application, + resource_owner_id: user.id, + scopes: 'read_write', + expires_in: 2.hours, + token: SecureRandom.hex(32) + ) + end + + let(:Authorization) { "Bearer #{access_token.token}" } + + let(:account) do + Account.create!( + family: family, + name: 'Test Checking', + balance: 1000, + currency: 'USD', + accountable: Depository.new + ) + end + + let!(:pending_import) do + family.imports.create!( + type: 'TransactionImport', + status: 'pending', + account: account, + raw_file_str: "date,amount,name\n01/01/2024,10.00,Test Transaction" + ) + end + + let!(:complete_import) do + family.imports.create!( + type: 'TransactionImport', + status: 'complete', + account: account, + raw_file_str: "date,amount,name\n01/02/2024,20.00,Another Transaction" + ) + end + + path '/api/v1/imports' do + get 'List imports' do + description 'List all imports for the user\'s family with pagination and filtering.' + tags 'Imports' + security [ { bearerAuth: [] } ] + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with read scope' + 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, + description: 'Filter by status', + schema: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] } + parameter name: :type, in: :query, required: false, + description: 'Filter by import type', + schema: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport] } + + response '200', 'imports listed' do + schema '$ref' => '#/components/schemas/ImportCollection' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.fetch('data')).to be_present + expect(payload.fetch('meta')).to include('current_page', 'total_count', 'total_pages', 'per_page') + end + end + + response '200', 'imports filtered by status' do + schema '$ref' => '#/components/schemas/ImportCollection' + + let(:status) { 'pending' } + + run_test! do |response| + payload = JSON.parse(response.body) + payload.fetch('data').each do |import| + expect(import.fetch('status')).to eq('pending') + end + end + end + + response '200', 'imports filtered by type' do + schema '$ref' => '#/components/schemas/ImportCollection' + + let(:type) { 'TransactionImport' } + + run_test! do |response| + payload = JSON.parse(response.body) + payload.fetch('data').each do |import| + expect(import.fetch('type')).to eq('TransactionImport') + end + end + end + end + + post 'Create import' do + description 'Create a new import from raw CSV content.' + tags 'Imports' + security [ { bearerAuth: [] } ] + consumes 'application/json' + produces 'application/json' + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with write scope' + + parameter name: :body, in: :body, required: true, schema: { + type: :object, + properties: { + raw_file_content: { + type: :string, + description: 'The raw CSV content as a string' + }, + type: { + type: :string, + enum: %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport], + description: 'Import type (defaults to TransactionImport)' + }, + account_id: { + type: :string, + format: :uuid, + description: 'Account ID to import into' + }, + publish: { + type: :string, + description: 'Set to "true" to automatically queue for processing if configuration is valid' + }, + date_col_label: { + type: :string, + description: 'Header name for the date column' + }, + amount_col_label: { + type: :string, + description: 'Header name for the amount column' + }, + name_col_label: { + type: :string, + description: 'Header name for the transaction name column' + }, + category_col_label: { + type: :string, + description: 'Header name for the category column' + }, + tags_col_label: { + type: :string, + description: 'Header name for the tags column' + }, + notes_col_label: { + type: :string, + description: 'Header name for the notes column' + }, + date_format: { + type: :string, + description: 'Date format pattern (e.g., "%m/%d/%Y")' + }, + number_format: { + type: :string, + enum: [ '1,234.56', '1.234,56', '1 234,56', '1,234' ], + description: 'Number format for parsing amounts' + }, + signage_convention: { + type: :string, + enum: %w[inflows_positive inflows_negative], + description: 'How to interpret positive/negative amounts' + }, + col_sep: { + type: :string, + enum: [ ',', ';' ], + description: 'Column separator' + } + } + } + + response '201', 'import created' do + schema '$ref' => '#/components/schemas/ImportResponse' + + let(:body) do + { + raw_file_content: "date,amount,name\n01/15/2024,50.00,New Transaction", + type: 'TransactionImport', + account_id: account.id, + date_col_label: 'date', + amount_col_label: 'amount', + name_col_label: 'name' + } + end + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.dig('data', 'id')).to be_present + expect(payload.dig('data', 'type')).to eq('TransactionImport') + expect(payload.dig('data', 'status')).to eq('pending') + end + end + + response '422', 'validation error - file too large' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) do + { + raw_file_content: 'x' * (11 * 1024 * 1024), # 11MB, exceeds MAX_CSV_SIZE + type: 'TransactionImport' + } + end + + run_test! + end + end + end + + path '/api/v1/imports/{id}' do + parameter name: :Authorization, in: :header, required: true, schema: { type: :string }, + description: 'Bearer token with read scope' + parameter name: :id, in: :path, type: :string, required: true, description: 'Import ID' + + get 'Retrieve an import' do + description 'Retrieve detailed information about a specific import, including configuration and row statistics.' + tags 'Imports' + security [ { bearerAuth: [] } ] + produces 'application/json' + + let(:id) { pending_import.id } + + response '200', 'import retrieved' do + schema '$ref' => '#/components/schemas/ImportResponse' + + run_test! do |response| + payload = JSON.parse(response.body) + expect(payload.dig('data', 'id')).to eq(pending_import.id) + expect(payload.dig('data', 'type')).to eq('TransactionImport') + expect(payload.dig('data', 'configuration')).to be_present + expect(payload.dig('data', 'stats')).to be_present + end + end + + response '404', 'import 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 7d523d2fc..6bdc27146 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -283,6 +283,86 @@ RSpec.configure do |config| properties: { message: { type: :string } } + }, + ImportConfiguration: { + type: :object, + properties: { + date_col_label: { type: :string, nullable: true }, + amount_col_label: { type: :string, nullable: true }, + name_col_label: { type: :string, nullable: true }, + category_col_label: { type: :string, nullable: true }, + tags_col_label: { type: :string, nullable: true }, + notes_col_label: { type: :string, nullable: true }, + account_col_label: { type: :string, nullable: true }, + date_format: { type: :string, nullable: true }, + number_format: { type: :string, nullable: true }, + signage_convention: { type: :string, nullable: true } + } + }, + ImportStats: { + type: :object, + properties: { + rows_count: { type: :integer, minimum: 0 }, + valid_rows_count: { type: :integer, minimum: 0, nullable: true } + } + }, + ImportSummary: { + type: :object, + required: %w[id type status created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport] }, + status: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' }, + account_id: { type: :string, format: :uuid, nullable: true }, + rows_count: { type: :integer, minimum: 0 }, + error: { type: :string, nullable: true } + } + }, + ImportDetail: { + type: :object, + required: %w[id type status created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string, enum: %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport] }, + status: { type: :string, enum: %w[pending complete importing reverting revert_failed failed] }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' }, + account_id: { type: :string, format: :uuid, nullable: true }, + error: { type: :string, nullable: true }, + configuration: { '$ref' => '#/components/schemas/ImportConfiguration' }, + stats: { '$ref' => '#/components/schemas/ImportStats' } + } + }, + ImportCollection: { + type: :object, + required: %w[data meta], + properties: { + data: { + type: :array, + items: { '$ref' => '#/components/schemas/ImportSummary' } + }, + meta: { + type: :object, + required: %w[current_page total_pages total_count per_page], + properties: { + current_page: { type: :integer, minimum: 1 }, + next_page: { type: :integer, nullable: true }, + prev_page: { type: :integer, nullable: true }, + total_pages: { type: :integer, minimum: 0 }, + total_count: { type: :integer, minimum: 0 }, + per_page: { type: :integer, minimum: 1 } + } + } + } + }, + ImportResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/ImportDetail' } + } } } }