diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml index 510c64049..0588162c0 100644 --- a/.github/workflows/helm-publish.yml +++ b/.github/workflows/helm-publish.yml @@ -40,6 +40,12 @@ jobs: - name: Resolve chart and app versions id: version shell: bash + # Bind workflow inputs to env so the values arrive as shell variables + # instead of being interpolated verbatim by the `${{ }}` runner pass. + # zizmor flags the direct expansion as a template-injection risk. + env: + CHART_VERSION_INPUT: ${{ inputs.chart_version }} + APP_VERSION_INPUT: ${{ inputs.app_version }} run: | set -euo pipefail @@ -48,18 +54,24 @@ jobs: echo "${raw#v}" } - if [ -n "${{ inputs.chart_version }}" ]; then - CHART_VERSION="$(normalize_version "${{ inputs.chart_version }}")" + if [ -n "$CHART_VERSION_INPUT" ]; then + CHART_VERSION="$(normalize_version "$CHART_VERSION_INPUT")" elif [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then CHART_VERSION="$(normalize_version "${GITHUB_REF_NAME}")" else CHART_VERSION="0.0.0-nightly.$(date -u +'%Y%m%d.%H%M%S')" fi - if [ -n "${{ inputs.app_version }}" ]; then - APP_VERSION="${{ inputs.app_version }}" + # Normalize APP_VERSION the same way CHART_VERSION is — appVersion + # must match the OCI image tag in GHCR, which is published without a + # leading `v`. Without this, a release on tag `v0.7.1-rc.1` writes + # `appVersion: "v0.7.1-rc.1"` into Chart.yaml / index.yaml, and Helm + # then fails to pull `ghcr.io/we-promise/sure:v0.7.1-rc.1` (the real + # tag is `0.7.1-rc.1`). See #2050. + if [ -n "$APP_VERSION_INPUT" ]; then + APP_VERSION="$(normalize_version "$APP_VERSION_INPUT")" elif [[ "${GITHUB_REF_TYPE}" == "tag" && "${GITHUB_REF_NAME}" == v* ]]; then - APP_VERSION="${GITHUB_REF_NAME}" + APP_VERSION="$(normalize_version "${GITHUB_REF_NAME}")" else APP_VERSION="${CHART_VERSION}" fi diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 13ae2f52c..8c698ab4b 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -153,7 +153,7 @@ jobs: working-directory: mobile if: ${{ steps.check_prereqs.outputs.enabled == 'true' }} env: - APP_BUNDLE_ID: am.sure.mobile + APP_BUNDLE_ID: ${{ vars.IOS_APP_BUNDLE_ID || 'am.sure.mobile' }} IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }} PROFILE_NAME: ${{ secrets.IOS_PROVISIONING_PROFILE_NAME }} IOS_DISTRIBUTION_CERT_NAME: ${{ secrets.IOS_DISTRIBUTION_CERT_NAME }} @@ -173,14 +173,16 @@ jobs: path = Path("ios/Runner.xcodeproj/project.pbxproj") text = path.read_text() + app_bundle_id = os.environ["APP_BUNDLE_ID"] team = os.environ["IOS_TEAM_ID"] profile = os.environ["PROFILE_NAME"] identity = os.environ["IOS_DISTRIBUTION_CERT_NAME"] def patch_block(match): block = match.group(0) - if "PRODUCT_BUNDLE_IDENTIFIER = am.sure.mobile;" not in block: + if "PRODUCT_BUNDLE_IDENTIFIER =" not in block: return block + block = re.sub(r'PRODUCT_BUNDLE_IDENTIFIER = .*?;', f'PRODUCT_BUNDLE_IDENTIFIER = {app_bundle_id};', block) if "CODE_SIGN_STYLE = Manual;" not in block: block = block.replace("CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";", "CURRENT_PROJECT_VERSION = \"$(FLUTTER_BUILD_NUMBER)\";\n\t\t\t\tCODE_SIGN_STYLE = Manual;") if '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "Apple Distribution";' not in block: @@ -246,6 +248,7 @@ jobs: -destination 'generic/platform=iOS' \ MARKETING_VERSION="$IOS_VERSION" \ CURRENT_PROJECT_VERSION="$IOS_BUILD_NUMBER" \ + PRODUCT_BUNDLE_IDENTIFIER="$APP_BUNDLE_ID" \ archive mkdir -p "$EXPORT_PATH" diff --git a/.rubocop.yml b/.rubocop.yml index 33542a43c..d313b5ab5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,6 +1,6 @@ inherit_gem: rubocop-rails-omakase: rubocop.yml - + Layout/IndentationWidth: Enabled: true diff --git a/app/assets/tailwind/sure-design-system/_generated.css b/app/assets/tailwind/sure-design-system/_generated.css index 79ae54d43..355025e3b 100644 --- a/app/assets/tailwind/sure-design-system/_generated.css +++ b/app/assets/tailwind/sure-design-system/_generated.css @@ -25,7 +25,7 @@ --color-container-inset: var(--color-gray-50); --color-container-inset-hover: var(--color-gray-100); --color-nav-indicator: var(--color-black); - --color-toggle-track: var(--color-gray-100); + --color-toggle-track: var(--color-gray-300); --color-destructive-subtle: var(--color-red-200); --color-gray-25: #FAFAFA; --color-gray-50: #F7F7F7; @@ -294,7 +294,7 @@ @apply text-gray-400; @variant theme-dark { - @apply text-gray-500; + @apply text-gray-400; } } @@ -342,7 +342,7 @@ @apply border-alpha-black-300; @variant theme-dark { - @apply border-alpha-white-400; + @apply border-alpha-white-500; } } @@ -350,7 +350,7 @@ @apply border-alpha-black-200; @variant theme-dark { - @apply border-alpha-white-300; + @apply border-alpha-white-400; } } @@ -362,7 +362,7 @@ @apply border-alpha-black-50; @variant theme-dark { - @apply border-alpha-white-100; + @apply border-alpha-white-200; } } @@ -375,7 +375,7 @@ } @utility border-destructive { - @apply border-red-500; + @apply border-red-600; @variant theme-dark { @apply border-red-400; @@ -447,7 +447,7 @@ } @utility button-bg-destructive { - @apply bg-red-500; + @apply bg-red-600; @variant theme-dark { @apply bg-red-400; @@ -455,7 +455,7 @@ } @utility button-bg-destructive-hover { - @apply bg-red-600; + @apply bg-red-700; @variant theme-dark { @apply bg-red-500; diff --git a/app/assets/tailwind/sure-design-system/components.css b/app/assets/tailwind/sure-design-system/components.css index 0d4ddc213..93b8a6c92 100644 --- a/app/assets/tailwind/sure-design-system/components.css +++ b/app/assets/tailwind/sure-design-system/components.css @@ -109,18 +109,27 @@ @variant theme-dark { &[type='checkbox'] { - @apply ring-gray-900 checked:text-white; - background-color: var(--color-gray-100); + @apply ring-gray-900 border-alpha-white-300; + background-color: transparent; } &[type='checkbox']:disabled { - @apply cursor-not-allowed opacity-80; - background-color: var(--color-gray-600); + @apply cursor-not-allowed opacity-80 border-transparent; + background-color: var(--color-gray-700); + } + + &[type='checkbox']:checked, + &[type='checkbox']:indeterminate { + @apply border-transparent; + background-color: var(--color-gray-100); } &[type='checkbox']:checked { - background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23808080' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); - background-color: var(--color-gray-100); + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); + } + + &[type='checkbox']:indeterminate { + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='%23171717' xmlns='http://www.w3.org/2000/svg'%3e%3crect x='3.5' y='7' width='9' height='2' rx='1'/%3e%3c/svg%3e"); } } } @@ -151,6 +160,30 @@ } } + /* + Horizontally scrollable table wrapper (#2137). `overflow-x: auto` so wide + tables scroll instead of clipping (the LLM-usage table was `overflow-hidden` + and pushed columns off-screen) or wrapping money mid-digit. The pure-CSS + "scroll shadow" gives the missing affordance: the cover gradients + (`--table-scroll-bg`, default container-inset) scroll WITH the content + (`background-attachment: local`) and hide the fixed shadow gradients at the + edges, so a soft edge-shadow only appears when there is more to scroll. + Theme-aware via `--color-shadow`. Set `--table-scroll-bg` to match the wrapper. + */ + .table-scroll { + --table-scroll-bg: var(--color-container-inset); + overflow-x: auto; + background: + linear-gradient(to right, var(--table-scroll-bg) 30%, transparent), + linear-gradient(to left, var(--table-scroll-bg) 30%, transparent) 100% 0, + radial-gradient(farthest-side at 0 50%, var(--color-shadow), transparent), + radial-gradient(farthest-side at 100% 50%, var(--color-shadow), transparent) 100% 0; + background-repeat: no-repeat; + background-color: var(--table-scroll-bg); + background-size: 32px 100%, 32px 100%, 12px 100%, 12px 100%; + background-attachment: local, local, scroll, scroll; + } + /* Chart hover tooltip surface (see utils/chart_tooltip.js for the JS-side contract). Matches the design reference exactly: hairline border ring diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index d7fa580a6..653151f95 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -9,7 +9,7 @@ class DS::Buttonish < DesignSystemComponent icon_classes: "text-primary" }, destructive: { - container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", + container_classes: "text-inverse bg-red-600 theme-dark:bg-red-400 hover:bg-red-700 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600", icon_classes: "text-inverse" }, outline: { diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index c77895cbd..07828a01c 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -6,7 +6,7 @@ <% end %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> diff --git a/app/components/UI/period_picker.html.erb b/app/components/UI/period_picker.html.erb new file mode 100644 index 000000000..d7ccbf302 --- /dev/null +++ b/app/components/UI/period_picker.html.erb @@ -0,0 +1,20 @@ +<%= render DS::Menu.new(variant: :button, placement: placement) do |menu| %> + <% menu.with_button( + type: "button", + class: "inline-flex items-center gap-1.5 bg-container border border-secondary font-medium rounded-lg pl-3 pr-2 py-2 text-sm cursor-pointer text-primary hover:bg-container-inset-hover focus:outline-hidden focus:ring-0", + aria: { label: t(".aria_label", period: selected_label, default: "Time period: %{period}") } + ) do %> + <%= selected_label %> + <%= helpers.icon("chevron-down", size: "sm") %> + <% end %> + + <% periods.each do |period| %> + <% menu.with_item( + variant: :link, + text: period.label_short, + href: href_for(period.key), + frame: frame, + selected: selected?(period.key) + ) %> + <% end %> +<% end %> diff --git a/app/components/UI/period_picker.rb b/app/components/UI/period_picker.rb new file mode 100644 index 000000000..60d75a459 --- /dev/null +++ b/app/components/UI/period_picker.rb @@ -0,0 +1,46 @@ +class UI::PeriodPicker < ApplicationComponent + # Unified time-range selector shared by the dashboard and account charts. + # + # Renders a DS::Menu as a flat list of link items — one per Period. Each item + # is a GET link to `url` carrying `?period=` (plus any `extra_params`), + # which re-renders `frame` (a Turbo Frame id) with the chosen period. When + # `frame` is nil the links fall back to a normal Turbo Drive visit. + # + # The selected period is marked with a check icon and `aria-current`, and the + # trigger button shows its label. + # + # NOTE: `url` must be a path without a query string; pass query state via + # `extra_params` so the picker can compose `?period=…` cleanly. + attr_reader :selected_key, :url, :frame, :extra_params, :placement + + def initialize(selected:, url:, frame: nil, extra_params: {}, placement: "bottom-end") + @selected_key = selected.respond_to?(:key) ? selected.key : selected.to_s + @url = url + @frame = frame + @extra_params = (extra_params || {}).symbolize_keys + @placement = placement + end + + def periods + Period.all + end + + def selected_label + period_for(selected_key).label_short + end + + def selected?(key) + key == selected_key + end + + def href_for(key) + "#{url}?#{extra_params.merge(period: key).to_query}" + end + + private + def period_for(key) + Period.from_key(key) + rescue Period::InvalidKeyError + Period.last_30_days + end +end diff --git a/app/controllers/api/v1/import_sessions_controller.rb b/app/controllers/api/v1/import_sessions_controller.rb new file mode 100644 index 000000000..f749124d7 --- /dev/null +++ b/app/controllers/api/v1/import_sessions_controller.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +class Api::V1::ImportSessionsController < Api::V1::BaseController + before_action :ensure_read_scope, only: [ :show ] + before_action :ensure_write_scope, only: [ :create, :create_chunk, :publish ] + before_action :set_import_session, only: [ :show, :create_chunk, :publish ] + + def create + @import_session = ImportSession.create_or_find_for!( + family: Current.family, + import_type: params[:type].to_s, + client_session_id: params[:client_session_id].presence, + expected_chunks: expected_chunks_param + ) + + render_import_session(status: :created) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + rescue ActiveRecord::RecordInvalid => e + render_error( + "validation_failed", + "Import session could not be created", + :unprocessable_entity, + errors: e.record.errors.full_messages + ) + end + + def show + render_import_session + end + + def create_chunk + content, filename, content_type = sure_import_upload_attributes + return unless content + + @import_session.attach_chunk!( + sequence: sequence_param, + client_chunk_id: params[:client_chunk_id].presence, + content: content, + filename: filename, + content_type: content_type + ) + + @import_session.reload + render_import_session(status: :created) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + rescue ActiveRecord::RecordInvalid => e + render_error( + "validation_failed", + "Import chunk could not be created", + :unprocessable_entity, + errors: e.record.errors.full_messages + ) + end + + def publish + @import_session.publish_later + @import_session.reload + render_import_session(status: :accepted) + rescue Import::MaxRowCountExceededError + render_error("max_row_count_exceeded", "Import session has too many rows to publish.", :unprocessable_entity) + rescue ImportSession::EnqueueError + render_error("import_enqueue_failed", "Import session could not be queued.", :service_unavailable) + rescue ImportSession::ConflictError => e + render_import_session_conflict(e.message) + end + + private + def set_import_session + @import_session = Current.family.import_sessions.find(params[:id]) + end + + def ensure_read_scope + authorize_scope!(:read) + end + + def ensure_write_scope + authorize_scope!(:write) + end + + def expected_chunks_param + return if params[:expected_chunks].blank? + + params[:expected_chunks] + end + + def sequence_param + raise ActionController::ParameterMissing.new(:sequence) if params[:sequence].blank? + + params[:sequence] + end + + def sure_import_upload_attributes + if params[:file].present? + sure_import_file_upload_attributes(params[:file]) + elsif params[:raw_file_content].present? + sure_import_raw_content_attributes(params[:raw_file_content].to_s) + else + render_error("missing_content", "Provide a Sure NDJSON file or raw_file_content.", :unprocessable_entity) + nil + end + end + + def sure_import_file_upload_attributes(file) + if file.size > SureImport.max_ndjson_size + render_error( + "file_too_large", + "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB.", + :unprocessable_entity + ) + return + end + + extension = File.extname(file.original_filename.to_s).downcase + unless SureImport::ALLOWED_NDJSON_CONTENT_TYPES.include?(file.content_type) || extension.in?(%w[.ndjson .json]) + render_error("invalid_file_type", "Invalid file type. Please upload a Sure NDJSON file.", :unprocessable_entity) + return + end + + sure_import_validated_attributes( + content: file.read, + filename: file.original_filename.presence || "sure-import.ndjson", + content_type: file.content_type.presence || "application/x-ndjson" + ) + end + + def sure_import_raw_content_attributes(content) + if content.bytesize > SureImport.max_ndjson_size + render_error( + "content_too_large", + "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB.", + :unprocessable_entity + ) + return + end + + sure_import_validated_attributes( + content: content, + filename: "sure-import.ndjson", + content_type: "application/x-ndjson" + ) + end + + def sure_import_validated_attributes(content:, filename:, content_type:) + unless SureImport.valid_ndjson_first_line?(content) + render_error("invalid_ndjson", "Invalid Sure NDJSON content.", :unprocessable_entity) + return + end + + [ content, filename, content_type ] + end + + def render_import_session_conflict(message) + render_error("import_session_conflict", message, :conflict) + end + + def render_import_session(status: :ok) + chunks = @import_session.imports.ordered_by_sequence.map do |import| + { + id: import.id, + sequence: import.sequence, + client_chunk_id: import.client_chunk_id, + status: import.status, + rows_count: import.rows_count, + summary: import.summary || {}, + error: import.error_details.presence, + created_at: import.created_at, + updated_at: import.updated_at + } + end + + render json: { + data: { + id: @import_session.id, + type: @import_session.import_type, + status: @import_session.status, + client_session_id: @import_session.client_session_id, + expected_chunks: @import_session.expected_chunks, + chunks_count: chunks.size, + summary: @import_session.summary || {}, + error: @import_session.error_details.presence, + created_at: @import_session.created_at, + updated_at: @import_session.updated_at, + chunks: chunks + } + }, status: status + end + + def render_error(error, message, status, errors: nil) + payload = { error: error, message: message } + payload[:errors] = errors if errors + render json: payload, status: status + end +end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 479ad9efc..18e38b15e 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -48,7 +48,14 @@ module AccountableResource @account.lock_saved_attributes! end - redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) + # Prefer the form-carried return_to, then the session value StoreLocation + # captured from `?return_to=` (survives multi-step flows where the param + # isn't threaded), then the account page. The form param is sanitized here + # (the session value is already filtered at store time); the session is + # consumed with delete so a stale value can't leak into a later flow. + return_path = safe_return_to(account_params[:return_to]) || session.delete(:return_to).presence || @account + redirect_to return_path, + notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize) end def update diff --git a/app/controllers/concerns/store_location.rb b/app/controllers/concerns/store_location.rb index e2e8d3181..14f98068d 100644 --- a/app/controllers/concerns/store_location.rb +++ b/app/controllers/concerns/store_location.rb @@ -24,9 +24,20 @@ private end def store_return_to - if params[:return_to].present? - session[:return_to] = params[:return_to] - end + safe = safe_return_to(params[:return_to]) + session[:return_to] = safe if safe + end + + # Only allow internal absolute paths (a single leading "/"). Blocks absolute + # URLs, protocol-relative ("//evil"), and backslash tricks ("/\\evil") so a + # crafted ?return_to= can't open-redirect — including via a custom + # turbo_stream redirect, which Rails' redirect host-guard does NOT cover + # (the client `Turbo.visit`es the target and full-navigates cross-origin). + def safe_return_to(value) + # is_a?(String) first: a crafted `?return_to[]=foo` makes params[:return_to] + # an Array, and Array#match? doesn't exist — without this guard the helper + # raises NoMethodError before the redirect hardening can reject it. + value if value.is_a?(String) && value.present? && value.match?(%r{\A/(?![/\\])}) end def clear_previous_path diff --git a/app/controllers/enable_banking_items_controller.rb b/app/controllers/enable_banking_items_controller.rb index 0bfc0dabb..901d0589c 100644 --- a/app/controllers/enable_banking_items_controller.rb +++ b/app/controllers/enable_banking_items_controller.rb @@ -131,39 +131,6 @@ class EnableBankingItemsController < ApplicationController return end - # Re-fetch ASPSP list from provider to avoid session cookie overflow. - # We do not store full ASPSP metadata in the session to stay within the 4KB limit; - # instead, we re-query the provider here for the final authorization parameters. - aspsp_data = nil - begin - provider_for_lookup = @enable_banking_item.enable_banking_provider - if provider_for_lookup - response = provider_for_lookup.get_aspsps(country: @enable_banking_item.country_code) - raw_aspsps = response[:aspsps] || response["aspsps"] || [] - found = raw_aspsps.find { |a| a[:name] == aspsp_name || a["name"] == aspsp_name } - aspsp_data = found&.with_indifferent_access - end - rescue Provider::EnableBanking::EnableBankingError => e - Rails.logger.warn "Enable Banking: could not fetch ASPSP metadata in authorize: #{e.message}" - end - - # Block DECOUPLED banks — our OAuth redirect flow doesn't support them - if aspsp_data.present? - # Adjust psu_type if the bank does not support the requested type - supported_types = Array(aspsp_data[:psu_types]).map(&:to_s) - if supported_types.any? && !supported_types.include?(psu_type) - psu_type = supported_types.first - end - - first_method = Array(aspsp_data[:auth_methods]).first - approach = first_method&.dig(:approach) || first_method&.dig("approach") - if approach == "DECOUPLED" - redirect_to settings_providers_path, alert: t(".decoupled_not_supported", - default: "This bank uses a separate device authentication method which is not yet supported. Please add this account manually.") - return - end - end - begin target_item = if params[:new_connection] == "true" Current.family.enable_banking_items.create!( @@ -181,12 +148,14 @@ class EnableBankingItemsController < ApplicationController language = I18n.locale.to_s.split("-").first - redirect_url = target_item.start_authorization( + # begin_authorization! re-fetches ASPSP metadata and auto-selects the best + # auth method (REDIRECT > DECOUPLED > EMBEDDED). Decoupled/MFA banks proceed + # through Enable Banking's hosted SCA page rather than being blocked. + redirect_url = target_item.begin_authorization!( aspsp_name: aspsp_name, redirect_url: enable_banking_callback_url, state: target_item.id, psu_type: psu_type, - aspsp_data: aspsp_data, language: language ) @@ -269,11 +238,11 @@ class EnableBankingItemsController < ApplicationController begin language = I18n.locale.to_s.split("-").first - redirect_url = @enable_banking_item.start_authorization( - aspsp_name: @enable_banking_item.aspsp_name, + # Route through the shared path so reauthorization re-selects the same auth + # method (decoupled banks included) instead of falling back to a default. + redirect_url = @enable_banking_item.begin_authorization!( redirect_url: enable_banking_callback_url, state: @enable_banking_item.id, - psu_type: @enable_banking_item.psu_type || "personal", language: language ) diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index c54a44371..1bb8f3c8a 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -26,7 +26,7 @@ class GoalsController < ApplicationController # entirely (rendered with filterable: false). @grid_goals = @active_goals + @completed_goals - @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count + @linkable_account_count = Current.user.accessible_accounts.where(accountable_type: "Depository").visible.count @kpi = kpi_payload(@active_goals) @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } @show_search = @grid_goals.size > 6 @@ -61,7 +61,7 @@ class GoalsController < ApplicationController def create @goal = Current.family.goals.new(goal_params) accounts = lookup_accounts(params.dig(:goal, :account_ids)) - @goal.currency = accounts.first.currency if accounts.any? && @goal.currency.blank? + @goal.currency = (accounts.first&.currency || Current.family.primary_currency_code) if @goal.currency.blank? Goal.transaction do accounts.each { |a| @goal.goal_accounts.build(account: a) } @@ -169,18 +169,24 @@ class GoalsController < ApplicationController return [] if ids.blank? ids = Array(ids).reject(&:blank?) - Current.family.accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a + Current.user.accessible_accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a end def linkable_accounts_for_new - Current.family.accounts.where(accountable_type: "Depository").visible.alphabetically.to_a + Current.user.accessible_accounts.where(accountable_type: "Depository").visible.alphabetically.to_a end def sync_linked_accounts!(goal, accounts) desired_ids = accounts.map(&:id).to_set current_ids = goal.goal_accounts.pluck(:account_id).to_set - (current_ids - desired_ids).each do |id| + # Only unlink accounts the current user can actually see in the picker. + # A family goal may be linked to another member's private account, which + # never renders as a checkbox — so its absence from the submitted set is + # not an intentional removal and must not destroy the link. + removable_ids = Current.user.accessible_accounts.where(id: current_ids.to_a).pluck(:id).to_set + + ((current_ids & removable_ids) - desired_ids).each do |id| goal.goal_accounts.where(account_id: id).destroy_all end additions = accounts.reject { |a| current_ids.include?(a.id) } diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb index 1de7a5f8c..9b00d4cee 100644 --- a/app/controllers/properties_controller.rb +++ b/app/controllers/properties_controller.rb @@ -75,9 +75,18 @@ class PropertiesController < ApplicationController if @account.draft? @account.activate! + # The property setup wizard (create → balances → address) is multi-step, + # so the original `?return_to=` only survives in the session (captured by + # StoreLocation), not as a threaded form param. Honor it on completion so + # flows like the savings-goals "Add an account" CTA land back where they + # started instead of on the account page. Sanitized + consumed: the + # turbo_stream branch below isn't covered by Rails' redirect host-guard, + # so an unsafe value must not reach stream_redirect_to. + return_path = safe_return_to(session.delete(:return_to)) || account_path(@account) + respond_to do |format| - format.html { redirect_to account_path(@account) } - format.turbo_stream { stream_redirect_to account_path(@account) } + format.html { redirect_to return_path } + format.turbo_stream { stream_redirect_to return_path } end else @success_message = "Address updated successfully." diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js index 0628d0315..3c358dd96 100644 --- a/app/javascript/controllers/time_series_chart_controller.js +++ b/app/javascript/controllers/time_series_chart_controller.js @@ -140,7 +140,18 @@ export default class extends Controller { .attr("d", this._d3Line) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") - .attr("stroke-width", this.strokeWidthValue); + // A flat series (no variation across the period — a single valuation or an + // unchanged balance) otherwise renders as a full-bleed near-black rule + // bisecting the hero card. Draw it as a faint hairline so it reads as + // "no change", consistent across light and dark (#2137). + .attr("stroke-width", this._isFlatSeries ? 1 : this.strokeWidthValue) + .attr("stroke-opacity", this._isFlatSeries ? 0.4 : 1); + } + + get _isFlatSeries() { + const min = d3.min(this._normalDataPoints, this._getDatumValue); + const max = d3.max(this._normalDataPoints, this._getDatumValue); + return min === max; } _installTrendlineSplit() { diff --git a/app/jobs/import_session_job.rb b/app/jobs/import_session_job.rb new file mode 100644 index 000000000..de7f0e377 --- /dev/null +++ b/app/jobs/import_session_job.rb @@ -0,0 +1,12 @@ +class ImportSessionJob < ApplicationJob + queue_as :high_priority + + def perform(import_session) + raise ArgumentError, "ImportSessionJob requires an import_session" if import_session.nil? + + Rails.logger.info("ImportSessionJob started import_session_id=#{import_session.id}") + import_session.publish + import_session.reload + Rails.logger.info("ImportSessionJob finished import_session_id=#{import_session.id} status=#{import_session.status}") + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 48a64b796..a2c144b8e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,7 +41,11 @@ class Account < ApplicationRecord enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } - scope :visible, -> { where(status: [ "draft", "active" ]) } + VISIBLE_STATUSES = %w[draft active].freeze + HISTORICAL_STATUSES = (VISIBLE_STATUSES + %w[disabled]).freeze + + scope :visible, -> { where(status: VISIBLE_STATUSES) } + scope :historical, -> { where(status: HISTORICAL_STATUSES) } scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } diff --git a/app/models/assistant/function/import_bank_statement.rb b/app/models/assistant/function/import_bank_statement.rb index dee54602f..d3bf86dec 100644 --- a/app/models/assistant/function/import_bank_statement.rb +++ b/app/models/assistant/function/import_bank_statement.rb @@ -93,6 +93,8 @@ class Assistant::Function::ImportBankStatement < Assistant::Function end # Extract transactions from the PDF using provider + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # extract_bank_statement (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) unless provider return { diff --git a/app/models/balance/chart_series_builder.rb b/app/models/balance/chart_series_builder.rb index c8c733579..b6aa9f7bc 100644 --- a/app/models/balance/chart_series_builder.rb +++ b/app/models/balance/chart_series_builder.rb @@ -1,10 +1,14 @@ class Balance::ChartSeriesBuilder - def initialize(account_ids:, currency:, period: Period.last_30_days, interval: nil, favorable_direction: "up") + def initialize(account_ids:, currency:, period: Period.last_30_days, interval: nil, + favorable_direction: "up", account_active_until_dates: {}) @account_ids = account_ids @currency = currency @period = period @interval = interval @favorable_direction = favorable_direction + @account_active_until_dates = account_active_until_dates.compact + .transform_keys(&:to_s) + .transform_values { |date| date.to_date.iso8601 } end def balance_series @@ -29,7 +33,7 @@ class Balance::ChartSeriesBuilder end private - attr_reader :account_ids, :currency, :period, :favorable_direction + attr_reader :account_ids, :currency, :period, :favorable_direction, :account_active_until_dates def interval @interval || period.interval @@ -74,7 +78,8 @@ class Balance::ChartSeriesBuilder start_date: period.start_date, end_date: period.end_date, interval: interval, - sign_multiplier: sign_multiplier + sign_multiplier: sign_multiplier, + account_active_until_dates_json: account_active_until_dates.to_json } ]) rescue => e @@ -96,6 +101,19 @@ class Balance::ChartSeriesBuilder SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date AS date UNION DISTINCT SELECT :end_date::date -- Ensure end date is included + ), + account_windows AS ( + SELECT + account_window.account_id::uuid AS account_id, + account_window.active_until_date::date AS active_until_date + FROM jsonb_each_text(CAST(:account_active_until_dates_json AS jsonb)) + AS account_window(account_id, active_until_date) + ), + selected_accounts AS ( + SELECT accounts.*, account_windows.active_until_date + FROM accounts + LEFT JOIN account_windows ON account_windows.account_id = accounts.id + WHERE accounts.id = ANY(array[:account_ids]::uuid[]) ) SELECT d.date, @@ -119,7 +137,8 @@ class Balance::ChartSeriesBuilder END * COALESCE(er.rate, 1) * :sign_multiplier::integer ), 0) AS start_holdings_balance FROM dates d - CROSS JOIN accounts + LEFT JOIN selected_accounts accounts + ON accounts.active_until_date IS NULL OR d.date <= accounts.active_until_date LEFT JOIN LATERAL ( SELECT b.end_balance, b.end_cash_balance, @@ -153,7 +172,6 @@ class Balance::ChartSeriesBuilder LIMIT 1) ) AS rate ) er ON TRUE - WHERE accounts.id = ANY(array[:account_ids]::uuid[]) GROUP BY d.date ORDER BY d.date SQL diff --git a/app/models/balance_sheet/historical_account_scope.rb b/app/models/balance_sheet/historical_account_scope.rb new file mode 100644 index 000000000..2906908e9 --- /dev/null +++ b/app/models/balance_sheet/historical_account_scope.rb @@ -0,0 +1,18 @@ +class BalanceSheet::HistoricalAccountScope + def initialize(family, user: nil) + @family = family + @user = user + end + + def account_ids + relation.pluck(:id) + end + + def relation + scope = family.accounts.historical + user.present? ? scope.included_in_finances_for(user) : scope + end + + private + attr_reader :family, :user +end diff --git a/app/models/balance_sheet/net_worth_series_builder.rb b/app/models/balance_sheet/net_worth_series_builder.rb index 7c29a6ece..58f97d321 100644 --- a/app/models/balance_sheet/net_worth_series_builder.rb +++ b/app/models/balance_sheet/net_worth_series_builder.rb @@ -7,7 +7,8 @@ class BalanceSheet::NetWorthSeriesBuilder def net_worth_series(period: Period.last_30_days) Rails.cache.fetch(cache_key(period)) do builder = Balance::ChartSeriesBuilder.new( - account_ids: visible_account_ids, + account_ids: historical_account_ids, + account_active_until_dates: disabled_account_active_until_dates, currency: family.currency, period: period, favorable_direction: "up" @@ -20,18 +21,31 @@ class BalanceSheet::NetWorthSeriesBuilder private attr_reader :family, :user - def visible_account_ids - @visible_account_ids ||= begin - scope = family.accounts.visible - scope = scope.included_in_finances_for(user) if user - scope.pluck(:id) + def historical_accounts + @historical_accounts ||= historical_account_scope.relation.to_a + end + + def historical_account_ids + @historical_account_ids ||= historical_accounts.map(&:id) + end + + def disabled_account_active_until_dates + @disabled_account_active_until_dates ||= historical_accounts.each_with_object({}) do |account, dates| + next unless account.disabled? + + disabled_on = (account.disabled_at || account.updated_at).to_date + dates[account.id] = disabled_on - 1.day end end + def historical_account_scope + @historical_account_scope ||= BalanceSheet::HistoricalAccountScope.new(family, user: user) + end + def cache_key(period) shares_version = user ? AccountShare.where(user: user).maximum(:updated_at)&.to_i : nil key = [ - "balance_sheet_net_worth_series", + "balance_sheet_net_worth_series_historical", user&.id, shares_version, period.start_date, diff --git a/app/models/enable_banking_item.rb b/app/models/enable_banking_item.rb index 7f3b6a540..87af6ec78 100644 --- a/app/models/enable_banking_item.rb +++ b/app/models/enable_banking_item.rb @@ -73,19 +73,31 @@ class EnableBankingItem < ApplicationRecord raise StandardError.new("Enable Banking provider is not configured") unless provider validated_psu_type = psu_type + selected_method = nil # Store ASPSP metadata before calling provider so it's available even if auth fails if aspsp_data.present? aspsp_data = aspsp_data.with_indifferent_access - first_auth_method = aspsp_data.dig(:auth_methods, 0) || aspsp_data.dig("auth_methods", 0) - aspsp_types = aspsp_data[:psu_types] || [] + aspsp_types = Array(aspsp_data[:psu_types]).map(&:to_s) + + # If the requested PSU type isn't supported by this ASPSP, fall back to the + # first type it advertises rather than failing outright. + validated_psu_type = if psu_type.present? && aspsp_types.include?(psu_type) + psu_type + elsif aspsp_types.any? + aspsp_types.first + else + psu_type + end + + selected_method = select_auth_method(aspsp_data, validated_psu_type) + update!( aspsp_required_psu_headers: aspsp_data[:required_psu_headers] || [], aspsp_maximum_consent_validity: aspsp_data[:maximum_consent_validity], - aspsp_auth_approach: first_auth_method&.dig(:approach) || first_auth_method&.dig("approach"), + aspsp_auth_approach: selected_method&.dig(:approach), aspsp_psu_types: aspsp_types ) - validated_psu_type = psu_type.present? && aspsp_types.include?(psu_type) ? psu_type : nil end result = provider.start_authorization( @@ -95,7 +107,8 @@ class EnableBankingItem < ApplicationRecord state: state, psu_type: validated_psu_type, maximum_consent_validity: aspsp_maximum_consent_validity, - language: language + language: language, + auth_method: selected_method&.dig(:name) ) attributes = { @@ -109,6 +122,26 @@ class EnableBankingItem < ApplicationRecord result[:url] end + # Shared entry point for both initial authorization and reauthorization. + # Re-fetches ASPSP metadata (so the auth method / PSU type selection and the + # stored approach stay accurate) and starts the provider authorization. The + # re-fetch — rather than caching the full ASPSP object in the session — keeps + # us under the 4KB session cookie limit. + # @return [String] Redirect URL for the user + def begin_authorization!(redirect_url:, state:, language: nil, psu_type: nil, aspsp_name: nil) + name = aspsp_name.presence || self.aspsp_name + raise StandardError.new("No bank selected for this connection") if name.blank? + + start_authorization( + aspsp_name: name, + redirect_url: redirect_url, + state: state, + psu_type: psu_type.presence || self.psu_type || "personal", + aspsp_data: fetch_aspsp_data(name), + language: language + ) + end + # Complete the authorization flow with the code from callback def complete_authorization(code:) provider = enable_banking_provider @@ -130,6 +163,27 @@ class EnableBankingItem < ApplicationRecord result end + # Reconcile the locally-stored session expiry with what the API reports. + # The session info returned by GET /sessions carries the authoritative + # access.valid_until; persisting it on every sync keeps session_valid? accurate + # and avoids both premature "expired" states and stale "still valid" states. + def reconcile_session_expiry!(session_data) + return unless session_data.is_a?(Hash) + + valid_until = session_data.dig(:access, :valid_until) || session_data.dig("access", "valid_until") + return if valid_until.blank? + + parsed = Time.zone.parse(valid_until.to_s) + return if parsed.nil? || parsed == session_expires_at + + update!(session_expires_at: parsed) + rescue ArgumentError, TypeError, ActiveRecord::ActiveRecordError => e + # Best-effort reconciliation: swallow bad timestamps (ArgumentError/TypeError) + # as well as validation/locking failures from update! (RecordInvalid, + # StaleObjectError) so a sync is never derailed by expiry bookkeeping. + Rails.logger.warn "EnableBankingItem #{id} - Failed to reconcile session expiry: #{e.message}" + end + def import_latest_enable_banking_data provider = enable_banking_provider unless provider @@ -288,6 +342,51 @@ class EnableBankingItem < ApplicationRecord private + # Authentication approach preference, lowest number wins. + # REDIRECT is the smoothest (PSU authenticates entirely on the ASPSP page). + # DECOUPLED works through Enable Banking's hosted page (push-to-app / photoTAN + # / chipTAN). EMBEDDED is last resort (handled by the hosted page too). + AUTH_APPROACH_PRIORITY = { "REDIRECT" => 0, "DECOUPLED" => 1, "EMBEDDED" => 2 }.freeze + + # Choose the best authentication method for the given PSU type. + # Returns a hash with :name and :approach, or nil when the ASPSP exposes no + # API-selectable methods (Enable Banking then falls back to its default). + def select_auth_method(aspsp_data, psu_type) + methods = Array(aspsp_data[:auth_methods]).map(&:with_indifferent_access) + return nil if methods.empty? + + # Hidden methods aren't surfaced on Enable Banking's hosted page, so we don't + # auto-select one (the PSU couldn't complete it). If every method is hidden, + # return nil and let /auth fall back to the ASPSP's default rather than + # forcing a non-selectable method. + methods = methods.reject { |m| ActiveModel::Type::Boolean.new.cast(m[:hidden_method]) } + return nil if methods.empty? + + # Prefer methods that match the chosen PSU type; if none declare a psu_type + # (or none match), consider all of them. + matching = methods.select { |m| m[:psu_type].blank? || m[:psu_type].to_s == psu_type.to_s } + candidates = matching.presence || methods + + best = candidates.min_by { |m| AUTH_APPROACH_PRIORITY.fetch(m[:approach].to_s, 99) } + return nil unless best + + { name: best[:name], approach: best[:approach] } + end + + # Fetch the ASPSP object for a given name from the provider's /aspsps list. + # Returns a HashWithIndifferentAccess, or nil if unavailable. + def fetch_aspsp_data(aspsp_name) + provider = enable_banking_provider + return nil unless provider + + response = provider.get_aspsps(country: country_code) + raw_aspsps = response[:aspsps] || response["aspsps"] || [] + raw_aspsps.find { |a| (a[:name] || a["name"]) == aspsp_name }&.with_indifferent_access + rescue Provider::EnableBanking::EnableBankingError => e + Rails.logger.warn "EnableBankingItem #{id} - could not fetch ASPSP metadata for #{aspsp_name}: #{e.message}" + nil + end + def parse_session_expiry(session_result) if session_result[:access].present? && session_result[:access][:valid_until].present? parsed = Time.zone.parse(session_result[:access][:valid_until]) diff --git a/app/models/enable_banking_item/importer.rb b/app/models/enable_banking_item/importer.rb index 696b4f673..d08f0c5f5 100644 --- a/app/models/enable_banking_item/importer.rb +++ b/app/models/enable_banking_item/importer.rb @@ -124,14 +124,20 @@ class EnableBankingItem::Importer private - def handle_sync_error(exception) + # @param session_level [Boolean] true only for the top-level GET /sessions call. + # A session-level 401/404 means the consent is genuinely dead and the user + # must re-authorize. Per-account 401/404 (a stale account UID, a transient + # hiccup on one account) must NOT mark the whole connection requires_update — + # doing so is what made every sync report "session expired". Those are recorded + # as ordinary sync errors and retried on the next sync. + def handle_sync_error(exception, session_level: false) # Check the underlying cause first, then the exception itself exceptions = [ exception.cause, exception ].compact provider_error = exceptions.find { |ex| ex.is_a?(Provider::EnableBanking::EnableBankingError) } - # Handle session expiration status update - if provider_error && [ :unauthorized, :not_found ].include?(provider_error.error_type) + # Handle session expiration status update (session-level failures only) + if session_level && provider_error && [ :unauthorized, :not_found ].include?(provider_error.error_type) enable_banking_item.update!(status: :requires_update) return I18n.t("enable_banking_items.errors.session_invalid") end @@ -151,14 +157,18 @@ class EnableBankingItem::Importer end def fetch_session_data - enable_banking_provider.get_session(session_id: enable_banking_item.session_id) + session_data = enable_banking_provider.get_session(session_id: enable_banking_item.session_id) + # Keep the local expiry in sync with the authoritative value from the API so + # session_valid? doesn't drift (premature "expired" or stale "still valid"). + enable_banking_item.reconcile_session_expiry!(session_data) + session_data rescue Provider::EnableBanking::EnableBankingError => e Rails.logger.error "EnableBankingItem::Importer - Enable Banking API error: #{e.message}" - @session_error = handle_sync_error(e) + @session_error = handle_sync_error(e, session_level: true) nil rescue => e Rails.logger.error "EnableBankingItem::Importer - Unexpected error fetching session: #{e.class} - #{e.message}" - @session_error = handle_sync_error(e) + @session_error = handle_sync_error(e, session_level: true) nil end diff --git a/app/models/enable_banking_item/syncer.rb b/app/models/enable_banking_item/syncer.rb index dfe8ce984..3049a05eb 100644 --- a/app/models/enable_banking_item/syncer.rb +++ b/app/models/enable_banking_item/syncer.rb @@ -8,11 +8,14 @@ class EnableBankingItem::Syncer end def perform_sync(sync) - # Check if session is valid before syncing + # An expired/missing session is an expected state that needs user action, not a + # hard failure. Mark the connection requires_update and finish the sync + # gracefully so the UI surfaces the "Reconnect" CTA instead of a red sync error. unless enable_banking_item.session_valid? sync.update!(status_text: "Session expired - re-authorization required") if sync.respond_to?(:status_text) enable_banking_item.update!(status: :requires_update) - raise StandardError.new("Enable Banking session has expired. Please re-authorize.") + collect_health_stats(sync, errors: nil) + return end # Phase 1: Import data from Enable Banking API @@ -20,6 +23,16 @@ class EnableBankingItem::Syncer import_result = enable_banking_item.import_latest_enable_banking_data unless import_result[:success] + # A session-level auth failure detected mid-import flips the item to + # requires_update — surface that as a graceful reconnect state, not a red + # error. Transient/per-account failures leave status good and fall through + # to a normal sync error that retries next time. + if enable_banking_item.requires_update? + sync.update!(status_text: "Re-authorization required") if sync.respond_to?(:status_text) + collect_health_stats(sync, errors: nil) + return + end + error_msg = import_result[:error] if error_msg.blank? && (import_result[:accounts_failed].to_i > 0 || import_result[:transactions_failed].to_i > 0) parts = [] diff --git a/app/models/family.rb b/app/models/family.rb index d4c6cf1e4..c5f8f2252 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -27,6 +27,8 @@ class Family < ApplicationRecord has_many :invitations, dependent: :destroy has_many :imports, dependent: :destroy + has_many :import_sessions, dependent: :destroy + has_many :import_source_mappings, dependent: :destroy has_many :family_exports, dependent: :destroy has_many :account_statements, dependent: :destroy diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 404218056..20e08a70d 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -545,6 +545,7 @@ class Family::DataExporter def serialize_rule_for_export(rule) { + id: rule.id, name: rule.name, resource_type: rule.resource_type, active: rule.active, diff --git a/app/models/family/data_importer.rb b/app/models/family/data_importer.rb index 4de55a9bc..7a68419c5 100644 --- a/app/models/family/data_importer.rb +++ b/app/models/family/data_importer.rb @@ -1,6 +1,36 @@ require "set" class Family::DataImporter + MissingReferenceError = Class.new(StandardError) do + attr_reader :code, :details + + def initialize(record_type:, source_type:, source_id:) + @code = "missing_source_reference" + @details = { + record_type: record_type, + source_type: source_type, + source_id: source_id + } + + super("#{record_type} references missing #{source_type} source id #{source_id}") + end + end + + InvalidRecordError = Class.new(StandardError) do + attr_reader :code, :details + + def initialize(record_type:, field:, value:) + @code = "invalid_import_record" + @details = { + record_type: record_type, + field: field, + value: value + } + + super("#{record_type} has invalid #{field}: #{value.inspect}") + end + end + SUPPORTED_TYPES = %w[Account Balance Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze ACCOUNTABLE_TYPE_CLASSES = { "Depository" => Depository, "Investment" => Investment, "Crypto" => Crypto, @@ -12,9 +42,41 @@ class Family::DataImporter ACCOUNTABLE_TYPE_CLASSES[type.to_s] end - def initialize(family, ndjson_content) + MAPPING_TYPES = { + accounts: "Account", + categories: "Category", + tags: "Tag", + merchants: "Merchant", + recurring_transactions: "RecurringTransaction", + transactions: "Transaction", + budgets: "Budget", + securities: "Security", + rules: "Rule" + }.freeze + SUMMARY_KEYS = { + "Account" => "accounts", + "Balance" => "balances", + "Category" => "categories", + "Tag" => "tags", + "Merchant" => "merchants", + "RecurringTransaction" => "recurring_transactions", + "Transaction" => "transactions", + "Transfer" => "transfers", + "RejectedTransfer" => "rejected_transfers", + "Trade" => "trades", + "Holding" => "holdings", + "Valuation" => "valuations", + "Budget" => "budgets", + "BudgetCategory" => "budget_categories", + "Rule" => "rules" + }.freeze + + def initialize(family, ndjson_content, import_session: nil, import: nil) @family = family @ndjson_content = ndjson_content + @import_session = import_session + @import = import + @strict_references = import_session.present? @id_mappings = { accounts: {}, categories: {}, @@ -23,11 +85,13 @@ class Family::DataImporter recurring_transactions: {}, transactions: {}, budgets: {}, - securities: {} + securities: {}, + rules: {} } @security_cache = {} @created_accounts = [] @created_entries = [] + @summary = Hash.new { |hash, key| hash[key] = empty_summary_bucket } end def import! @@ -54,7 +118,7 @@ class Family::DataImporter import_rules(records["Rule"] || []) end - { accounts: @created_accounts, entries: @created_entries } + { accounts: @created_accounts, entries: @created_entries, summary: compact_summary } end private @@ -79,6 +143,128 @@ class Family::DataImporter records end + def empty_summary_bucket + { "created" => 0, "updated" => 0, "skipped" => 0, "failed" => 0 } + end + + def compact_summary + @summary.select { |_entity_type, counts| counts.values.any?(&:positive?) } + end + + def increment_summary(record_type, status) + @summary[SUMMARY_KEYS.fetch(record_type)].tap do |counts| + counts[status.to_s] = counts.fetch(status.to_s, 0) + 1 + end + end + + def map_source!(mapping_key, source_id, target) + return if source_id.blank? || target.blank? + + @id_mappings[mapping_key][source_id] = target.id + return unless @import_session + + source_type = MAPPING_TYPES.fetch(mapping_key) + mapping = @import_session.source_mappings.find_or_initialize_by( + family: @family, + source_type: source_type, + source_id: source_id + ) + mapping.target = target + mapping.save! + end + + def mapped_id(mapping_key, old_id, record_type:, required: true) + if old_id.blank? + missing_reference(record_type, mapping_key, "(blank)") if required + return + end + + return @id_mappings[mapping_key][old_id] if @id_mappings[mapping_key].key?(old_id) + + source_type = MAPPING_TYPES.fetch(mapping_key) + mapping = @import_session&.source_mappings&.find_by( + family: @family, + source_type: source_type, + source_id: old_id + ) + + if mapping + @id_mappings[mapping_key][old_id] = mapping.target_id + return mapping.target_id + end + + if required && @strict_references + raise MissingReferenceError.new( + record_type: record_type, + source_type: source_type, + source_id: old_id + ) + end + + nil + end + + def mapped_record(mapping_key, old_id, scope, record_type:) + target_id = mapped_id(mapping_key, old_id, record_type: record_type, required: false) + return if target_id.blank? + + scope.find_by(id: target_id) + end + + def missing_reference(record_type, mapping_key, old_id) + if @strict_references + increment_summary(record_type, :failed) + raise MissingReferenceError.new( + record_type: record_type, + source_type: MAPPING_TYPES.fetch(mapping_key), + source_id: old_id + ) + end + + increment_summary(record_type, :skipped) + nil + end + + def require_source_id!(record_type, source_id) + return if source_id.present? || !@strict_references + + increment_summary(record_type, :failed) + raise MissingReferenceError.new( + record_type: record_type, + source_type: record_type, + source_id: "(blank)" + ) + end + + def invalid_record!(record_type, field, value) + if @strict_references + increment_summary(record_type, :failed) + raise InvalidRecordError.new(record_type: record_type, field: field, value: value) + end + + increment_summary(record_type, :skipped) + nil + end + + def session_entry_source + return unless @import_session + + "sure_import_session:#{@import_session.id}" + end + + def session_entry_external_id(record_type, source_id) + return if @import_session.blank? || source_id.blank? + + "#{record_type}:#{source_id}" + end + + def session_imported_entry(account, record_type, source_id) + external_id = session_entry_external_id(record_type, source_id) + return if external_id.blank? + + account.entries.find_by(source: session_entry_source, external_id: external_id) + end + def import_accounts(records) records.each do |record| data = record["data"] @@ -86,26 +272,41 @@ class Family::DataImporter accountable_data = data["accountable"] || {} accountable_type = data["accountable_type"] + require_source_id!("Account", old_id) + accountable_class = self.class.accountable_class_for(accountable_type) - next unless accountable_class - accountable = accountable_class.new - accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"] - - # Copy any other accountable attributes - safe_accountable_attrs = %w[subtype locked_attributes] - safe_accountable_attrs.each do |attr| - if accountable.respond_to?("#{attr}=") && accountable_data[attr].present? - accountable.send("#{attr}=", accountable_data[attr]) - end + unless accountable_class + invalid_record!("Account", "accountable_type", accountable_type) + next end - account = @family.accounts.build( + account = mapped_record(:accounts, old_id, @family.accounts, record_type: "Account") + created = account.blank? + + if account + accountable = account.accountable + else + # Build accountable + accountable = accountable_class.new + accountable.subtype = accountable_data["subtype"] if accountable.respond_to?(:subtype=) && accountable_data["subtype"] + + # Copy any other accountable attributes + safe_accountable_attrs = %w[subtype locked_attributes] + safe_accountable_attrs.each do |attr| + if accountable.respond_to?("#{attr}=") && accountable_data[attr].present? + accountable.send("#{attr}=", accountable_data[attr]) + end + end + + account = @family.accounts.build(accountable: accountable) + end + + account.assign_attributes( name: data["name"], balance: data["balance"].to_d, cash_balance: data["cash_balance"]&.to_d || data["balance"].to_d, currency: data["currency"] || @family.currency, - accountable: accountable, subtype: data["subtype"], institution_name: data["institution_name"], institution_domain: data["institution_domain"], @@ -118,7 +319,7 @@ class Family::DataImporter # Set opening balance if we have a historical balance and the import # does not provide either an explicit opening-anchor valuation or an # authoritative balance-history stream for this account. - if data["balance"].present? && !skip_opening_balance_import?(old_id, data) + if created && data["balance"].present? && !skip_opening_balance_import?(old_id, data) manager = Account::OpeningBalanceManager.new(account) result = manager.set_opening_balance( balance: data["balance"].to_d, @@ -127,8 +328,9 @@ class Family::DataImporter log_failed_opening_balance_import(account, old_id, result) unless result.success? end - @id_mappings[:accounts][old_id] = account.id - @created_accounts << account + map_source!(:accounts, old_id, account) + @created_accounts << account if created + increment_summary("Account", created ? :created : :updated) end end @@ -139,16 +341,23 @@ class Family::DataImporter def import_balances(records) records.each do |record| data = record["data"] || {} - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Balance") balance_date = parse_import_date(data["date"]) - next if new_account_id.blank? || balance_date.blank? || data["balance"].blank? + next if new_account_id.blank? + + if balance_date.blank? || data["balance"].blank? + increment_summary("Balance", :skipped) + next + end account = @family.accounts.find(new_account_id) currency = data["currency"].presence || account.currency balance = account.balances.find_or_initialize_by(date: balance_date, currency: currency) + created = balance.new_record? balance.assign_attributes(imported_balance_attributes(data)) balance.save! + increment_summary("Balance", created ? :created : :updated) end end @@ -188,24 +397,30 @@ class Family::DataImporter old_id = data["id"] parent_id = data["parent_id"] + require_source_id!("Category", old_id) + # Store parent relationship for second pass parent_mappings[old_id] = parent_id if parent_id.present? - category = @family.categories.build( + category = mapped_record(:categories, old_id, @family.categories, record_type: "Category") + created = category.blank? + category ||= @family.categories.build + + category.assign_attributes( name: data["name"], color: data["color"] || Category::UNCATEGORIZED_COLOR, classification_unused: data["classification_unused"] || data["classification"] || "expense", lucide_icon: data["lucide_icon"] || "shapes" ) category.save! - - @id_mappings[:categories][old_id] = category.id + map_source!(:categories, old_id, category) + increment_summary("Category", created ? :created : :updated) end # Second pass: establish parent relationships parent_mappings.each do |old_id, old_parent_id| - new_id = @id_mappings[:categories][old_id] - new_parent_id = @id_mappings[:categories][old_parent_id] + new_id = mapped_id(:categories, old_id, record_type: "Category") + new_parent_id = mapped_id(:categories, old_parent_id, record_type: "Category") next unless new_id && new_parent_id @@ -219,13 +434,22 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - tag = @family.tags.build( + require_source_id!("Tag", old_id) + + tag = mapped_record(:tags, old_id, @family.tags, record_type: "Tag") + created = tag.blank? + tag ||= @family.tags.build + color = data["color"] || tag.color + # Keep replayed session imports deterministic when the source omits a color. + color ||= Tag::COLORS.first if created + + tag.assign_attributes( name: data["name"], - color: data["color"] || Tag::COLORS.sample + color: color ) tag.save! - - @id_mappings[:tags][old_id] = tag.id + map_source!(:tags, old_id, tag) + increment_summary("Tag", created ? :created : :updated) end end @@ -234,14 +458,20 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - merchant = @family.merchants.build( + require_source_id!("Merchant", old_id) + + merchant = mapped_record(:merchants, old_id, @family.merchants, record_type: "Merchant") + created = merchant.blank? + merchant ||= @family.merchants.build + + merchant.assign_attributes( name: data["name"], color: data["color"], logo_url: data["logo_url"] ) merchant.save! - - @id_mappings[:merchants][old_id] = merchant.id + map_source!(:merchants, old_id, merchant) + increment_summary("Merchant", created ? :created : :updated) end end @@ -250,10 +480,20 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - new_account_id = remap_optional_id(:accounts, data["account_id"]) + require_source_id!("RecurringTransaction", old_id) + + recurring_transaction = mapped_record( + :recurring_transactions, + old_id, + @family.recurring_transactions, + record_type: "RecurringTransaction" + ) + created = recurring_transaction.blank? + + new_account_id = remap_optional_id(:accounts, data["account_id"], record_type: "RecurringTransaction") next if data["account_id"].present? && new_account_id.blank? - new_merchant_id = remap_optional_id(:merchants, data["merchant_id"]) + new_merchant_id = remap_optional_id(:merchants, data["merchant_id"], record_type: "RecurringTransaction") next if data["merchant_id"].present? && new_merchant_id.blank? expected_day_of_month = recurring_expected_day_for(data["expected_day_of_month"]) @@ -262,7 +502,8 @@ class Family::DataImporter next_expected_date = parse_import_date(data["next_expected_date"]) next unless last_occurrence_date && next_expected_date - recurring_transaction = @family.recurring_transactions.build( + recurring_transaction ||= @family.recurring_transactions.build + recurring_transaction.assign_attributes( account_id: new_account_id, merchant_id: new_merchant_id, amount: data["amount"].to_d, @@ -280,14 +521,15 @@ class Family::DataImporter ) recurring_transaction.save! - @id_mappings[:recurring_transactions][old_id] = recurring_transaction.id + map_source!(:recurring_transactions, old_id, recurring_transaction) + increment_summary("RecurringTransaction", created ? :created : :updated) end end - def remap_optional_id(mapping_key, old_id) + def remap_optional_id(mapping_key, old_id, record_type:) return if old_id.blank? - @id_mappings[mapping_key][old_id] + mapped_id(mapping_key, old_id, record_type: record_type) end def recurring_transaction_status_for(status) @@ -312,8 +554,10 @@ class Family::DataImporter data = record["data"] old_id = data["id"] + require_source_id!("Transaction", old_id) + # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Transaction") next unless new_account_id account = @family.accounts.find(new_account_id) @@ -321,55 +565,69 @@ class Family::DataImporter # Map category ID (optional) new_category_id = nil if data["category_id"].present? - new_category_id = @id_mappings[:categories][data["category_id"]] + new_category_id = mapped_id(:categories, data["category_id"], record_type: "Transaction") end # Map merchant ID (optional) new_merchant_id = nil if data["merchant_id"].present? - new_merchant_id = @id_mappings[:merchants][data["merchant_id"]] + new_merchant_id = mapped_id(:merchants, data["merchant_id"], record_type: "Transaction") end # Map tag IDs (optional) - new_tag_ids = mapped_tag_ids(data["tag_ids"]) + new_tag_ids = mapped_tag_ids(data["tag_ids"], record_type: "Transaction") - transaction = Transaction.new( + entry = session_imported_entry(account, "Transaction", old_id) + transaction = entry&.entryable if entry&.entryable.is_a?(Transaction) + created = transaction.blank? + + transaction ||= Transaction.new + transaction.assign_attributes( category_id: new_category_id, merchant_id: new_merchant_id, kind: data["kind"] || "standard" ) - entry = Entry.new( + entry ||= Entry.new(entryable: transaction) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: data["name"] || "Imported transaction", currency: data["currency"] || account.currency, notes: data["notes"], - excluded: data["excluded"] || false, - entryable: transaction + excluded: data["excluded"] || false ) + if @import_session + entry.external_id = session_entry_external_id("Transaction", old_id) + entry.source = session_entry_source + end entry.save! - @id_mappings[:transactions][old_id] = transaction.id + map_source!(:transactions, old_id, transaction) split_rows = importable_split_rows(data) if split_rows.any? - @created_entries << entry + @created_entries << entry if created import_split_lines!(entry, split_rows, fallback_tag_ids: new_tag_ids) else + transaction.taggings.destroy_all unless created new_tag_ids.each do |tag_id| transaction.taggings.create!(tag_id: tag_id) end - @created_entries << entry + @created_entries << entry if created end + + increment_summary("Transaction", created ? :created : :updated) end end - def mapped_tag_ids(old_tag_ids) - Array(old_tag_ids).map { |old_tag_id| @id_mappings[:tags][old_tag_id] }.compact + def mapped_tag_ids(old_tag_ids, record_type:) + Array(old_tag_ids).map do |old_tag_id| + mapped_id(:tags, old_tag_id, record_type: record_type) + end.compact end def importable_split_rows(data) @@ -380,8 +638,8 @@ class Family::DataImporter amount = row["amount"] || row["amount_money"] || row["amount_decimal"] next if amount.blank? - category_id = remap_optional_id(:categories, row["category_id"]) - merchant_id = remap_optional_id(:merchants, row["merchant_id"]) + category_id = remap_optional_id(:categories, row["category_id"], record_type: "Transaction") + merchant_id = remap_optional_id(:merchants, row["merchant_id"], record_type: "Transaction") { old_id: row["id"], @@ -392,7 +650,7 @@ class Family::DataImporter merchant_id_provided: row.key?("merchant_id"), notes: row["notes"], excluded: boolean_import_value(row, "excluded", default: false), - tag_ids: mapped_tag_ids(row["tag_ids"]), + tag_ids: mapped_tag_ids(row["tag_ids"], record_type: "Transaction"), tag_ids_provided: row.key?("tag_ids"), kind: row["kind"] } @@ -424,7 +682,7 @@ class Family::DataImporter transaction.taggings.create!(tag_id: tag_id) end - @id_mappings[:transactions][row[:old_id]] = transaction.id if row[:old_id].present? + map_source!(:transactions, row[:old_id], transaction) if row[:old_id].present? @created_entries << child_entry end end @@ -432,31 +690,60 @@ class Family::DataImporter 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"]] + inflow_transaction_id = mapped_id(:transactions, data["inflow_transaction_id"], record_type: "Transfer") + outflow_transaction_id = mapped_id(:transactions, data["outflow_transaction_id"], record_type: "Transfer") next unless inflow_transaction_id && outflow_transaction_id - Transfer.find_or_create_by!( + transfer = 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 + apply_transfer_transaction_kinds!(transfer) + increment_summary("Transfer", transfer.previously_new_record? ? :created : :updated) end end + def apply_transfer_transaction_kinds!(transfer) + destination_account = transfer.inflow_transaction.entry.account + outflow_kind = imported_transfer_outflow_kind(transfer) + outflow_attrs = { kind: outflow_kind } + if outflow_kind == "investment_contribution" && transfer.outflow_transaction.category_id.blank? + outflow_attrs[:category] = destination_account.family.investment_contributions_category + end + + transfer.outflow_transaction.update!(outflow_attrs) + transfer.inflow_transaction.update!(kind: "funds_movement") + end + + def imported_transfer_outflow_kind(transfer) + source_account = transfer.outflow_transaction.entry.account + destination_account = transfer.inflow_transaction.entry.account + return "loan_payment" if destination_account.loan? + return "cc_payment" if destination_account.liability? + return "investment_contribution" if investment_account?(destination_account) && !investment_account?(source_account) + + "funds_movement" + end + + def investment_account?(account) + account.investment? || account.crypto? + 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"]] + inflow_transaction_id = mapped_id(:transactions, data["inflow_transaction_id"], record_type: "RejectedTransfer") + outflow_transaction_id = mapped_id(:transactions, data["outflow_transaction_id"], record_type: "RejectedTransfer") next unless inflow_transaction_id && outflow_transaction_id - RejectedTransfer.find_or_create_by!( + rejected_transfer = RejectedTransfer.find_or_create_by!( inflow_transaction_id: inflow_transaction_id, outflow_transaction_id: outflow_transaction_id ) + increment_summary("RejectedTransfer", rejected_transfer.previously_new_record? ? :created : :updated) end end @@ -471,9 +758,12 @@ class Family::DataImporter def import_trades(records) records.each do |record| data = record["data"] + old_id = data["id"] + + require_source_id!("Trade", old_id) # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Trade") next unless new_account_id account = @family.accounts.find(new_account_id) @@ -490,34 +780,47 @@ class Family::DataImporter exchange_operating_mic: data["exchange_operating_mic"] ) - trade = Trade.new( + entry = session_imported_entry(account, "Trade", old_id) + trade = entry&.entryable if entry&.entryable.is_a?(Trade) + created = trade.blank? + + trade ||= Trade.new + trade.assign_attributes( security: security, qty: data["qty"].to_d, price: data["price"].to_d, currency: data["currency"] || account.currency ) - entry = Entry.new( + entry ||= Entry.new(entryable: trade) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: "#{data["qty"].to_d >= 0 ? 'Buy' : 'Sell'} #{ticker}", - currency: data["currency"] || account.currency, - entryable: trade + currency: data["currency"] || account.currency ) + if @import_session + entry.external_id = session_entry_external_id("Trade", old_id) + entry.source = session_entry_source + end entry.save! - @created_entries << entry + @created_entries << entry if created + increment_summary("Trade", created ? :created : :updated) end end def import_holdings(records) - accounts_by_id = @family.accounts.where(id: records.filter_map { |record| @id_mappings[:accounts][record.dig("data", "account_id")] }).index_by(&:id) + account_ids = records.filter_map do |record| + mapped_id(:accounts, record.dig("data", "account_id"), record_type: "Holding", required: false) + end + accounts_by_id = @family.accounts.where(id: account_ids).index_by(&:id) records.each do |record| data = record["data"] - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Holding") next unless new_account_id account = accounts_by_id[new_account_id] @@ -552,33 +855,46 @@ class Family::DataImporter security_locked: truthy?(data["security_locked"]) || false } - upsert_imported_holding!(account, security, holding_date, holding_currency, holding_attributes) + created = upsert_imported_holding!(account, security, holding_date, holding_currency, holding_attributes) + increment_summary("Holding", created ? :created : :updated) end end def import_valuations(records) records.each do |record| data = record["data"] + old_id = data["id"] + + require_source_id!("Valuation", old_id) # Map account ID - new_account_id = @id_mappings[:accounts][data["account_id"]] + new_account_id = mapped_id(:accounts, data["account_id"], record_type: "Valuation") next unless new_account_id account = @family.accounts.find(new_account_id) - valuation = Valuation.new(kind: valuation_kind_for(data["kind"])) + entry = session_imported_entry(account, "Valuation", old_id) + valuation = entry&.entryable if entry&.entryable.is_a?(Valuation) + created = valuation.blank? + valuation ||= Valuation.new + valuation.kind = valuation_kind_for(data["kind"]) - entry = Entry.new( + entry ||= Entry.new(entryable: valuation) + entry.assign_attributes( account: account, date: Date.parse(data["date"].to_s), amount: data["amount"].to_d, name: data["name"] || "Valuation", - currency: data["currency"] || account.currency, - entryable: valuation + currency: data["currency"] || account.currency ) + if @import_session + entry.external_id = session_entry_external_id("Valuation", old_id) + entry.source = session_entry_source + end entry.save! - @created_entries << entry + @created_entries << entry if created + increment_summary("Valuation", created ? :created : :updated) end end @@ -650,7 +966,13 @@ class Family::DataImporter data = record["data"] old_id = data["id"] - budget = @family.budgets.build( + require_source_id!("Budget", old_id) + + budget = mapped_record(:budgets, old_id, @family.budgets, record_type: "Budget") + created = budget.blank? + budget ||= @family.budgets.build + + budget.assign_attributes( start_date: Date.parse(data["start_date"].to_s), end_date: Date.parse(data["end_date"].to_s), budgeted_spending: data["budgeted_spending"]&.to_d, @@ -659,7 +981,8 @@ class Family::DataImporter ) budget.save! - @id_mappings[:budgets][old_id] = budget.id + map_source!(:budgets, old_id, budget) + increment_summary("Budget", created ? :created : :updated) end end @@ -668,36 +991,49 @@ class Family::DataImporter data = record["data"] # Map budget ID - new_budget_id = @id_mappings[:budgets][data["budget_id"]] + new_budget_id = mapped_id(:budgets, data["budget_id"], record_type: "BudgetCategory") next unless new_budget_id # Map category ID - new_category_id = @id_mappings[:categories][data["category_id"]] + new_category_id = mapped_id(:categories, data["category_id"], record_type: "BudgetCategory") next unless new_category_id budget = @family.budgets.find(new_budget_id) - budget_category = budget.budget_categories.build( + budget_category = budget.budget_categories.find_or_initialize_by(category_id: new_category_id) + created = budget_category.new_record? + budget_category.assign_attributes( category_id: new_category_id, budgeted_spending: data["budgeted_spending"].to_d, currency: data["currency"] || budget.currency ) budget_category.save! + increment_summary("BudgetCategory", created ? :created : :updated) end end def import_rules(records) records.each do |record| data = record["data"] + old_id = data["id"] - rule = @family.rules.build( + require_source_id!("Rule", old_id) + + rule = mapped_record(:rules, old_id, @family.rules, record_type: "Rule") + created = rule.blank? + rule ||= @family.rules.build + + rule.assign_attributes( name: data["name"], resource_type: data["resource_type"] || "transaction", active: data["active"] || false, effective_date: data["effective_date"].present? ? Date.parse(data["effective_date"].to_s) : nil ) + rule.conditions.destroy_all unless created + rule.actions.destroy_all unless created + # Build conditions (data["conditions"] || []).each do |condition_data| build_rule_condition(rule, condition_data) @@ -709,6 +1045,8 @@ class Family::DataImporter end rule.save! + map_source!(:rules, old_id, rule) + increment_summary("Rule", created ? :created : :updated) end end @@ -845,8 +1183,9 @@ class Family::DataImporter return security end - if old_security_id.present? && @id_mappings[:securities][old_security_id] - security = Security.find(@id_mappings[:securities][old_security_id]) + mapped_security_id = mapped_id(:securities, old_security_id, record_type: "Security", required: false) + if old_security_id.present? && mapped_security_id + security = Security.find(mapped_security_id) apply_security_metadata(security, normalized_ticker, attributes) @security_cache[cache_key] = security return security @@ -856,7 +1195,7 @@ class Family::DataImporter apply_security_metadata(security, normalized_ticker, attributes) @security_cache[cache_key] = security - @id_mappings[:securities][old_security_id] = security.id if old_security_id.present? + map_source!(:securities, old_security_id, security) if old_security_id.present? security end @@ -901,6 +1240,7 @@ class Family::DataImporter def upsert_imported_holding!(account, security, date, currency, attributes) holding = account.holdings.find_or_initialize_by(security: security, date: date, currency: currency) + created = holding.new_record? holding.assign_attributes(attributes) begin @@ -908,7 +1248,10 @@ class Family::DataImporter rescue ActiveRecord::RecordNotUnique existing = account.holdings.find_by!(security: security, date: date, currency: currency) existing.update!(attributes) + created = false end + + created end def security_kind_for(value) diff --git a/app/models/family/financial_data_reset.rb b/app/models/family/financial_data_reset.rb index 91759e239..021f1eab9 100644 --- a/app/models/family/financial_data_reset.rb +++ b/app/models/family/financial_data_reset.rb @@ -7,6 +7,8 @@ class Family::FinancialDataReset account_statements family_exports imports + import_sessions + import_source_mappings import_rows import_mappings accounts @@ -127,6 +129,7 @@ class Family::FinancialDataReset delete_active_storage_attachments! scope(:transfers).destroy_all scope(:rejected_transfers).destroy_all + scope(:import_source_mappings).destroy_all scope(:import_mappings).destroy_all scope(:import_rows).destroy_all scope(:rule_runs).destroy_all @@ -138,6 +141,7 @@ class Family::FinancialDataReset scope(:account_statements).destroy_all scope(:family_exports).destroy_all scope(:imports).destroy_all + scope(:import_sessions).destroy_all scope(:entries).destroy_all scope(:holdings).destroy_all scope(:balances).destroy_all @@ -239,6 +243,7 @@ class Family::FinancialDataReset account_scope = Account.where(family_id: family.id) account_ids = account_scope.select(:id) import_scope = Import.where(family_id: family.id) + import_session_scope = ImportSession.where(family_id: family.id) import_ids = import_scope.select(:id) rule_scope = Rule.where(family_id: family.id) rule_ids = rule_scope.select(:id) @@ -252,6 +257,8 @@ class Family::FinancialDataReset account_statements: AccountStatement.where(family_id: family.id), family_exports: FamilyExport.where(family_id: family.id), imports: import_scope, + import_sessions: import_session_scope, + import_source_mappings: ImportSourceMapping.where(family_id: family.id), import_rows: Import::Row.where(import_id: import_ids), import_mappings: Import::Mapping.where(import_id: import_ids), accounts: account_scope, diff --git a/app/models/import.rb b/app/models/import.rb index d70a19eb2..4ea45f684 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -41,11 +41,14 @@ class Import < ApplicationRecord belongs_to :family belongs_to :account, optional: true belongs_to :account_statement, optional: true + belongs_to :import_session, optional: true before_validation :set_default_number_format before_validation :ensure_utf8_encoding + normalizes :client_chunk_id, with: ->(value) { value.strip.presence } scope :ordered, -> { order(created_at: :desc) } + scope :ordered_by_sequence, -> { order(:sequence, :created_at) } enum :status, { pending: "pending", @@ -61,9 +64,15 @@ class Import < ApplicationRecord validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } + validates :sequence, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true + validates :client_chunk_id, length: { maximum: 255 }, allow_blank: true + validates :checksum, length: { is: 64 }, allow_blank: true validate :custom_column_import_requires_identifier validates :rows_to_skip, numericality: { only_integer: true, greater_than_or_equal_to: 0 } validate :account_belongs_to_family + validate :import_session_belongs_to_family + validate :session_chunk_metadata + validate :session_payloads_are_json_objects validate :rows_to_skip_within_file_bounds has_many :rows, dependent: :destroy @@ -564,6 +573,25 @@ class Import < ApplicationRecord errors.add(:account, "must belong to your family") end + def import_session_belongs_to_family + return if import_session.nil? + return if import_session.family_id == family_id + + errors.add(:import_session, "must belong to your family") + end + + def session_chunk_metadata + return if import_session.nil? + + errors.add(:sequence, "must be present for import session chunks") if sequence.blank? + errors.add(:checksum, "must be present for import session chunks") if checksum.blank? + end + + def session_payloads_are_json_objects + errors.add(:summary, "must be an object") unless summary.is_a?(Hash) + errors.add(:error_details, "must be an object") unless error_details.is_a?(Hash) + end + def rows_to_skip_within_file_bounds return if raw_file_str.blank? return if rows_to_skip.to_i == 0 diff --git a/app/models/import_session.rb b/app/models/import_session.rb new file mode 100644 index 000000000..8ce8c1fd9 --- /dev/null +++ b/app/models/import_session.rb @@ -0,0 +1,425 @@ +require "digest" + +class ImportSession < ApplicationRecord + ConflictError = Class.new(StandardError) + EnqueueError = Class.new(StandardError) + + IMPORT_TYPES = %w[SureImport].freeze + STATUSES = %w[pending importing complete failed].freeze + + belongs_to :family + has_many :imports, -> { order(:sequence, :created_at) }, dependent: :destroy + has_many :source_mappings, + class_name: "ImportSourceMapping", + dependent: :destroy + + enum :status, { + pending: "pending", + importing: "importing", + complete: "complete", + failed: "failed" + }, validate: true, default: "pending" + + validates :import_type, inclusion: { in: IMPORT_TYPES } + validates :client_session_id, uniqueness: { scope: :family_id }, allow_blank: true + validates :client_session_id, length: { maximum: 255 }, allow_blank: true + normalizes :client_session_id, with: ->(value) { value.strip.presence } + validates :expected_chunks, + numericality: { only_integer: true, greater_than: 0 }, + allow_nil: true + validate :payloads_are_json_objects + + def self.create_or_find_for!(family:, import_type:, client_session_id:, expected_chunks:) + import_type = import_type.presence || "SureImport" + expected_chunks = normalize_positive_integer(expected_chunks) + unless IMPORT_TYPES.include?(import_type) + session = new(import_type: import_type) + session.errors.add(:import_type, "must be SureImport") + raise ActiveRecord::RecordInvalid.new(session) + end + + if client_session_id.present? + session = family.import_sessions.find_or_initialize_by(client_session_id: client_session_id) + if session.persisted? && + expected_chunks.present? && + session.expected_chunks.present? && + session.expected_chunks != expected_chunks + raise ConflictError, "client_session_id already exists with a different expected_chunks value" + end + else + session = family.import_sessions.build + end + + session.import_type = import_type + session.expected_chunks ||= expected_chunks + session.save! + session + rescue ActiveRecord::RecordNotUnique + raise unless client_session_id.present? + + existing = family.import_sessions.find_by(client_session_id: client_session_id) + raise unless existing + + if expected_chunks.present? && + existing.expected_chunks.present? && + existing.expected_chunks != expected_chunks + raise ConflictError, "client_session_id already exists with a different expected_chunks value" + end + if expected_chunks.present? && existing.expected_chunks.nil? + existing.update!(expected_chunks: expected_chunks) + end + + existing + end + + def self.normalize_positive_integer(value) + return if value.blank? + + Integer(value, exception: false) || 0 + end + private_class_method :normalize_positive_integer + + def attach_chunk!(sequence:, content:, filename:, content_type:, client_chunk_id: nil) + sequence = self.class.send(:normalize_positive_integer, sequence) + raise ConflictError, "sequence must be a positive integer" unless sequence.positive? + raise ConflictError, "sequence exceeds expected_chunks" if expected_chunks.present? && sequence > expected_chunks + + checksum = Digest::SHA256.hexdigest(content) + normalized_client_chunk_id = client_chunk_id.presence + chunk_needs_finalization = false + + chunk = with_lock do + raise ConflictError, "cannot add chunks after publishing starts" unless pending? || failed? + + existing = existing_chunk_for!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum + ) + + if existing + chunk_needs_finalization = prepare_existing_chunk_for_retry!( + existing, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + existing + else + chunk_needs_finalization = true + chunk = create_chunk!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + end + end + + finalize_chunk_for_retry!(chunk, checksum) if chunk_needs_finalization + chunk + rescue ActiveRecord::RecordNotUnique + imports.reset + existing = existing_chunk_for!( + sequence: sequence, + client_chunk_id: normalized_client_chunk_id, + checksum: checksum + ) + return prepare_and_finalize_existing_chunk!( + existing, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) if existing + + raise ConflictError, "chunk already exists with different content" + end + + def create_chunk!(sequence:, client_chunk_id:, checksum:, content:, filename:, content_type:) + imports.create!( + family: family, + type: "SureImport", + sequence: sequence, + client_chunk_id: client_chunk_id, + checksum: checksum + ).tap do |import| + import.ndjson_file.attach( + io: StringIO.new(content), + filename: filename, + content_type: content_type + ) + end + end + private :create_chunk! + + def publish_later + previous_status = nil + should_enqueue = false + + sync_chunk_row_counts! + + with_lock do + return if complete? || importing? + + validate_publishable_chunks! + + previous_status = status + update!(status: :importing, error_details: {}) + should_enqueue = true + end + + return unless should_enqueue + + begin + ImportSessionJob.perform_later(self) + rescue => error + with_lock do + reload + if importing? + update!(status: previous_status, error_details: enqueue_error_details) + end + end + Rails.logger.error("ImportSession enqueue failed import_session_id=#{id} exception=#{error.class}") + raise EnqueueError, "Import session could not be queued." + end + end + + def publish + return unless prepare_for_publish! + + Rails.logger.info("ImportSession publish started import_session_id=#{id}") + + imports.ordered_by_sequence.each do |import| + process_chunk!(import) + end + + update!(status: :complete, summary: aggregate_chunk_summaries, error_details: {}) + enqueue_family_sync + Rails.logger.info("ImportSession publish completed import_session_id=#{id}") + rescue => error + update!( + status: :failed, + error_details: error_details_for(error), + summary: aggregate_chunk_summaries + ) + Rails.logger.error("ImportSession publish failed import_session_id=#{id} exception=#{error.class}") + end + + def aggregate_chunk_summaries + imports.reload.each_with_object({}) do |import, totals| + merge_summary!(totals, import.summary || {}) + end + end + + private + def prepare_for_publish! + sync_chunk_row_counts! + + with_lock do + return false if complete? + + validate_publishable_chunks! + + update!(status: :importing, error_details: {}) unless importing? + true + end + end + + def enqueue_family_sync + family.sync_later + rescue => error + update!(error_details: sync_enqueue_error_details) + Rails.logger.error( + "ImportSession family sync enqueue failed import_session_id=#{id} exception=#{error.class}" + ) + end + + def existing_chunk_for!(sequence:, client_chunk_id:, checksum:) + sequence_match = imports.find_by(sequence: sequence) + client_chunk_match = imports.find_by(client_chunk_id: client_chunk_id) if client_chunk_id.present? + + if sequence_match && client_chunk_match && sequence_match.id != client_chunk_match.id + raise ConflictError, "sequence and client_chunk_id refer to different chunks" + end + + existing = sequence_match || client_chunk_match + return unless existing + + if existing.sequence != sequence + raise ConflictError, "client_chunk_id already exists with a different sequence" + end + + if client_chunk_id.present? && existing.client_chunk_id.present? && existing.client_chunk_id != client_chunk_id + raise ConflictError, "sequence already exists with a different client_chunk_id" + end + + raise ConflictError, "chunk already exists with different content" unless existing.checksum == checksum + + existing + end + + def prepare_and_finalize_existing_chunk!(chunk, checksum:, content:, filename:, content_type:) + needs_finalization = with_lock do + prepare_existing_chunk_for_retry!( + chunk.reload, + checksum: checksum, + content: content, + filename: filename, + content_type: content_type + ) + end + + finalize_chunk_for_retry!(chunk, checksum) if needs_finalization + chunk + end + + def prepare_existing_chunk_for_retry!(chunk, checksum:, content:, filename:, content_type:) + return false if chunk_ready_for_retry?(chunk, checksum) + return true if chunk.ndjson_file.attached? && chunk_content_checksum(chunk) == checksum + + chunk.ndjson_file.attach( + io: StringIO.new(content), + filename: filename, + content_type: content_type + ) + true + end + + def finalize_chunk_for_retry!(chunk, checksum) + chunk.sync_ndjson_rows_count! + chunk.reload + return chunk if chunk_ready_for_retry?(chunk, checksum) + + raise ConflictError, "chunk already exists but is incomplete" + rescue ActiveStorage::FileNotFoundError + raise ConflictError, "chunk already exists but is incomplete" + end + + def chunk_ready_for_retry?(chunk, checksum) + chunk.ndjson_file.attached? && + chunk.rows_count.to_i.positive? && + chunk_content_checksum(chunk) == checksum + end + + def chunk_content_checksum(chunk) + Digest::SHA256.hexdigest(chunk.ndjson_file.download) + rescue ActiveStorage::FileNotFoundError + nil + end + + def process_chunk!(import) + return if import.complete? + + import.update!(status: :importing, error: nil, error_details: {}) + result = import.import!(import_session: self) + import.update!(status: :complete, summary: result.fetch(:summary, {}), error_details: {}) + rescue => error + import.update!( + status: :failed, + error: public_error_message_for(error), + error_details: error_details_for(error), + summary: failed_summary_for(error) + ) + raise + end + + def row_count_exceeded? + imports.sum(:rows_count) > SureImport.max_row_count + end + + def validate_publishable_chunks! + raise ConflictError, "import session has no chunks" unless imports.exists? + raise Import::MaxRowCountExceededError if row_count_exceeded? + validate_expected_chunk_sequences! + end + + def sync_chunk_row_counts! + raise ConflictError, "import session has no chunks" unless imports.exists? + imports.reload.each(&:sync_ndjson_rows_count!) + rescue ActiveStorage::FileNotFoundError + raise ConflictError, "import session chunks are incomplete" + end + + def validate_expected_chunk_sequences! + return if expected_chunks.blank? + + expected_sequences = (1..expected_chunks).to_a + actual_sequences = imports.pluck(:sequence).sort + return if actual_sequences == expected_sequences + + missing_sequences = expected_sequences - actual_sequences + unexpected_sequences = actual_sequences - expected_sequences + details = [] + details << "missing sequences: #{missing_sequences.join(', ')}" if missing_sequences.any? + details << "unexpected sequences: #{unexpected_sequences.join(', ')}" if unexpected_sequences.any? + + raise ConflictError, "import session chunks do not match expected sequences (#{details.join('; ')})" + end + + def error_details_for(error) + details = { + "code" => error.respond_to?(:code) ? error.code : "import_failed", + "message" => public_error_message_for(error) + } + + if error.respond_to?(:details) + details.merge!(error.details.stringify_keys) + end + + details + end + + def public_error_message_for(error) + return error.message if error.respond_to?(:code) + + "Import session failed." + end + + def enqueue_error_details + { + "code" => "import_enqueue_failed", + "message" => "Import session could not be queued." + } + end + + def sync_enqueue_error_details + { + "code" => "family_sync_enqueue_failed", + "message" => "Family sync could not be queued after import completion." + } + end + + def merge_summary!(totals, summary) + summary.each do |entity_type, counts| + next unless counts.respond_to?(:each) + + totals[entity_type] ||= {} + counts.each do |status, count| + totals[entity_type][status] = totals[entity_type].fetch(status, 0) + count.to_i + end + end + end + + def failed_summary_for(error) + record_type = error_details_for(error)["record_type"] + return {} if record_type.blank? + + { + record_type.to_s.underscore.pluralize => { + "created" => 0, + "updated" => 0, + "skipped" => 0, + "failed" => 1 + } + } + end + + def payloads_are_json_objects + errors.add(:summary, "must be an object") unless summary.is_a?(Hash) + errors.add(:error_details, "must be an object") unless error_details.is_a?(Hash) + end +end diff --git a/app/models/import_source_mapping.rb b/app/models/import_source_mapping.rb new file mode 100644 index 000000000..c59bd8a81 --- /dev/null +++ b/app/models/import_source_mapping.rb @@ -0,0 +1,41 @@ +class ImportSourceMapping < ApplicationRecord + SOURCE_TYPES = %w[Account Category Tag Merchant RecurringTransaction Transaction Budget Security Rule].freeze + + belongs_to :family + belongs_to :import_session + belongs_to :target, polymorphic: true, optional: true + + validates :source_type, :source_id, :target_type, :target_id, presence: true + validates :source_type, inclusion: { in: SOURCE_TYPES } + validates :target_type, inclusion: { in: SOURCE_TYPES }, allow_blank: true + validates :source_type, length: { maximum: 64 } + validates :source_id, length: { maximum: 255 } + validates :source_id, uniqueness: { scope: [ :import_session_id, :source_type ] } + normalizes :source_type, :source_id, with: ->(value) { value.strip.presence } + validate :family_matches_import_session + validate :target_exists + validate :target_matches_family + + private + def family_matches_import_session + return if import_session.blank? || family_id == import_session.family_id + + errors.add(:family, "must match import session") + end + + def target_exists + return if target_type.blank? || target_id.blank? || !SOURCE_TYPES.include?(target_type) + return if target.present? + + errors.add(:target, "must exist") + end + + def target_matches_family + return if target_type.blank? || !SOURCE_TYPES.include?(target_type) + return if target.blank? + return unless target.respond_to?(:family_id) + return if target.family_id == family_id + + errors.add(:target, "must belong to your family") + end +end diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index 1e1f494ef..69c74cb9e 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -89,6 +89,8 @@ class PdfImport < Import end def process_with_ai + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # process_pdf (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) raise "AI provider not configured" unless provider raise "AI provider does not support PDF processing" unless provider.supports_pdf_processing? @@ -115,6 +117,8 @@ class PdfImport < Import def extract_transactions return unless statement_with_transactions? + # TODO(#2113): hardcoded to OpenAI. Provider::Anthropic implements + # extract_bank_statement (PR #1985); this should honor Setting.llm_provider. provider = Provider::Registry.get_provider(:openai) raise "AI provider not configured" unless provider diff --git a/app/models/period.rb b/app/models/period.rb index b4857daee..b5a3c80b6 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -124,7 +124,10 @@ class Period def current_month_for(family) return from_key("current_month") unless family&.uses_custom_month_start? - family.current_custom_month_period + # Keep the semantic key so callers (e.g. the period picker) can identify + # this as "current month" even though the date range is custom. + custom_period = family.current_custom_month_period + new(key: "current_month", start_date: custom_period.start_date, end_date: custom_period.end_date) end def last_month_for(family) @@ -134,7 +137,7 @@ class Period last_month_date = current_start - 1.day start_date = family.custom_month_start_for(last_month_date) end_date = family.custom_month_end_for(last_month_date) - custom(start_date: start_date, end_date: end_date) + new(key: "last_month", start_date: start_date, end_date: end_date) end end diff --git a/app/models/provider/anthropic.rb b/app/models/provider/anthropic.rb index 8e2e1fa50..ab05a0e0b 100644 --- a/app/models/provider/anthropic.rb +++ b/app/models/provider/anthropic.rb @@ -155,11 +155,50 @@ class Provider::Anthropic < Provider end def process_pdf(pdf_content:, model: "", family: nil) - raise Error, "process_pdf not yet implemented for Provider::Anthropic" + with_provider_response do + effective_model = model.presence || @default_model + raise Error, "Model does not support PDF processing: #{effective_model}" unless supports_pdf_processing?(model: effective_model) + + trace = create_langfuse_trace( + name: "anthropic.process_pdf", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = PdfProcessor.new( + client, + model: effective_model, + pdf_content: pdf_content, + langfuse_trace: trace, + family: family + ).process + + upsert_langfuse_trace(trace: trace, output: result.to_h) + + result + end end def extract_bank_statement(pdf_content:, model: "", family: nil) - raise Error, "extract_bank_statement not yet implemented for Provider::Anthropic" + with_provider_response do + effective_model = model.presence || @default_model + + trace = create_langfuse_trace( + name: "anthropic.extract_bank_statement", + input: { pdf_size: pdf_content&.bytesize } + ) + + result = BankStatementExtractor.new( + client: client, + pdf_content: pdf_content, + model: effective_model, + langfuse_trace: trace, + family: family + ).extract + + upsert_langfuse_trace(trace: trace, output: { transaction_count: result[:transactions].size }) + + result + end end def chat_response( diff --git a/app/models/provider/anthropic/bank_statement_extractor.rb b/app/models/provider/anthropic/bank_statement_extractor.rb new file mode 100644 index 000000000..e91d44b47 --- /dev/null +++ b/app/models/provider/anthropic/bank_statement_extractor.rb @@ -0,0 +1,229 @@ +class Provider::Anthropic::BankStatementExtractor + include Provider::Anthropic::Concerns::UsageRecorder + + TOOL_NAME = "report_bank_statement".freeze + + # Mirrors Provider::Anthropic::PdfProcessor::MAX_PDF_BYTES. + MAX_PDF_BYTES = 32 * 1024 * 1024 + + attr_reader :client, :model, :pdf_content, :langfuse_trace, :family + + def initialize(client:, model:, pdf_content:, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @langfuse_trace = langfuse_trace + @family = family + end + + def extract + raise Provider::Anthropic::Error, "PDF content is required" if pdf_content.blank? + if pdf_content.bytesize > MAX_PDF_BYTES + raise Provider::Anthropic::Error, + "PDF exceeds Anthropic's 32 MB limit (#{pdf_content.bytesize} bytes)" + end + + span = langfuse_trace&.span(name: "extract_bank_statement_api_call", input: { + model: model, + pdf_size: pdf_content.bytesize + }) + + response = client.messages.create( + model: model, + max_tokens: max_tokens, + system_: instructions, + messages: [ { role: "user", content: user_content } ], + tools: [ output_tool ], + tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true } + ) + + parsed = extract_tool_input(response) + result = build_result(parsed) + + truncated = stop_reason(response) == :max_tokens + if truncated + Rails.logger.warn( + "[BankStatementExtractor] response truncated by max_tokens — extracted #{result[:transactions].size} " \ + "transactions but more may be present in the statement. Raise ANTHROPIC_MAX_TOKENS or chunk the PDF." + ) + result[:truncated] = true + end + + record_usage(model, response.usage, operation: "extract_bank_statement", metadata: { + pdf_size: pdf_content.bytesize, + transaction_count: result[:transactions].size, + truncated: truncated + }) + + span&.end(output: { transaction_count: result[:transactions].size }, usage: usage_hash(response.usage)) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + record_usage_error(model, operation: "extract_bank_statement", error: e, metadata: { pdf_size: pdf_content&.bytesize }) + raise + end + + private + def max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def user_content + [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Base64.strict_encode64(pdf_content) + } + }, + { + type: "text", + text: "Extract every transaction from this bank statement and return them via the report_bank_statement tool." + } + ] + end + + def output_tool + { + name: TOOL_NAME, + description: "Return the full set of transactions and statement metadata extracted from the PDF.", + input_schema: { + type: "object", + properties: { + bank_name: { type: [ "string", "null" ] }, + account_holder: { type: [ "string", "null" ] }, + account_number: { type: [ "string", "null" ], description: "Typically last 4 digits only." }, + statement_period: { + type: "object", + properties: { + start_date: { type: [ "string", "null" ], description: "YYYY-MM-DD" }, + end_date: { type: [ "string", "null" ], description: "YYYY-MM-DD" } + }, + required: [], + additionalProperties: false + }, + opening_balance: { type: [ "number", "null" ] }, + closing_balance: { type: [ "number", "null" ] }, + transactions: { + type: "array", + description: "Every transaction in the statement, in document order.", + items: { + type: "object", + properties: { + date: { type: "string", description: "YYYY-MM-DD" }, + description: { type: "string" }, + amount: { type: "number", description: "Negative for debits / expenses, positive for credits / deposits." }, + reference: { type: [ "string", "null" ] }, + category: { type: [ "string", "null" ] } + }, + required: [ "date", "description", "amount" ], + additionalProperties: false + } + } + }, + required: [ "transactions" ], + additionalProperties: false + } + } + end + + def instructions + <<~INSTRUCTIONS + Extract bank statement data from the attached PDF and return the result via the report_bank_statement tool. + + Rules: + - Extract EVERY transaction in document order + - Negative amounts for debits / expenses, positive for credits / deposits + - Dates in YYYY-MM-DD + - Use null for any field you cannot read; do not invent values + INSTRUCTIONS + end + + def stop_reason(response) + raw = response.respond_to?(:stop_reason) ? response.stop_reason : nil + raw.to_s.to_sym if raw + end + + def extract_tool_input(response) + tool_use = Array(response.content).find { |block| block_type(block) == :tool_use } + raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use + + input = block_input(tool_use) + input = JSON.parse(input) if input.is_a?(String) + input + end + + def build_result(parsed) + # Intentionally NOT deduplicated, unlike Provider::Openai's extractor. That + # one chunks the PDF text with overlap and must drop transactions repeated + # across adjacent chunks. We send the whole PDF as a single native document + # block — no chunk artifacts — so deduping here would wrongly merge + # legitimate same-day, same-amount rows (e.g. two identical purchases). + # Preserve every transaction the model returns. + transactions = Array(parsed["transactions"] || parsed[:transactions]).map { |t| normalize_transaction(t) }.compact + + { + transactions: transactions, + period: { + start_date: dig_period(parsed, :start_date), + end_date: dig_period(parsed, :end_date) + }, + account_holder: parsed["account_holder"] || parsed[:account_holder], + account_number: parsed["account_number"] || parsed[:account_number], + bank_name: parsed["bank_name"] || parsed[:bank_name], + opening_balance: parsed["opening_balance"] || parsed[:opening_balance], + closing_balance: parsed["closing_balance"] || parsed[:closing_balance] + } + end + + def dig_period(parsed, key) + period = parsed["statement_period"] || parsed[:statement_period] + return nil unless period.is_a?(Hash) + period[key.to_s] || period[key] + end + + def normalize_transaction(txn) + return nil unless txn.is_a?(Hash) + + { + date: parse_date(txn["date"] || txn[:date]), + amount: parse_amount(txn["amount"] || txn[:amount]), + name: txn["description"] || txn[:description] || txn["name"] || txn[:name], + category: txn["category"] || txn[:category], + notes: txn["reference"] || txn[:reference] + } + end + + def parse_date(date_str) + return nil if date_str.blank? + Date.parse(date_str.to_s).strftime("%Y-%m-%d") + rescue ArgumentError + nil + end + + def parse_amount(amount) + return nil if amount.nil? + return amount.to_f if amount.is_a?(Numeric) + amount.to_s.gsub(/[^0-9.\-]/, "").to_f + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_input(block) + block.respond_to?(:input) ? block.input : (block[:input] || block["input"]) + end + + def usage_hash(raw_usage) + return {} unless raw_usage + { + "input_tokens" => raw_usage.input_tokens.to_i, + "output_tokens" => raw_usage.output_tokens.to_i, + "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i + } + end +end diff --git a/app/models/provider/anthropic/pdf_processor.rb b/app/models/provider/anthropic/pdf_processor.rb new file mode 100644 index 000000000..dc6dc2c96 --- /dev/null +++ b/app/models/provider/anthropic/pdf_processor.rb @@ -0,0 +1,185 @@ +class Provider::Anthropic::PdfProcessor + include Provider::Anthropic::Concerns::UsageRecorder + + TOOL_NAME = "report_document_analysis".freeze + + # Anthropic enforces a 32 MB limit on the whole Messages *request body*, and + # the PDF travels base64-encoded (~4/3 larger) inside that body alongside the + # JSON envelope (instructions, tool schema). So a 32 MB raw PDF would encode + # to ~42 MB and be rejected. Cap the raw bytes at 3/4 of the request budget, + # minus a generous envelope reserve, so the encoded request stays under the + # limit. Guarding upstream also avoids base64-encoding an over-size blob in + # vain (peak heap before the API would reject it). + MAX_REQUEST_BYTES = 32 * 1024 * 1024 + REQUEST_ENVELOPE_BYTES = 1 * 1024 * 1024 + MAX_PDF_BYTES = (MAX_REQUEST_BYTES - REQUEST_ENVELOPE_BYTES) * 3 / 4 + + attr_reader :client, :model, :pdf_content, :langfuse_trace, :family + + def initialize(client, model:, pdf_content:, langfuse_trace: nil, family: nil) + @client = client + @model = model + @pdf_content = pdf_content + @langfuse_trace = langfuse_trace + @family = family + end + + def process + raise Provider::Anthropic::Error, "PDF content is required" if pdf_content.blank? + if pdf_content.bytesize > MAX_PDF_BYTES + raise Provider::Anthropic::Error, + "PDF is too large (#{pdf_content.bytesize} bytes); base64-encoded it would exceed Anthropic's 32 MB request limit" + end + + span = langfuse_trace&.span(name: "process_pdf_api_call", input: { + model: model, + pdf_size: pdf_content&.bytesize + }) + + response = client.messages.create( + model: model, + max_tokens: max_tokens, + system_: instructions, + messages: [ { role: "user", content: user_content } ], + tools: [ output_tool ], + tool_choice: { type: "tool", name: TOOL_NAME, disable_parallel_tool_use: true } + ) + + parsed = extract_tool_input(response) + result = build_result(parsed) + + record_usage(model, response.usage, operation: "process_pdf", metadata: { pdf_size: pdf_content.bytesize }) + + span&.end(output: result.to_h, usage: usage_hash(response.usage)) + result + rescue => e + span&.end(output: { error: e.message }, level: "ERROR") + record_usage_error(model, operation: "process_pdf", error: e, metadata: { pdf_size: pdf_content&.bytesize }) + raise + end + + private + PdfProcessingResult = Provider::LlmConcept::PdfProcessingResult + + def max_tokens + ENV.fetch("ANTHROPIC_MAX_TOKENS", 4096).to_i + end + + def user_content + [ + { + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: Base64.strict_encode64(pdf_content) + } + }, + { + type: "text", + text: "Analyze the attached document and return the result via the report_document_analysis tool." + } + ] + end + + def output_tool + { + name: TOOL_NAME, + description: "Return the structured analysis of the attached document.", + input_schema: { + type: "object", + properties: { + document_type: { + type: "string", + enum: Import::DOCUMENT_TYPES, + description: "Classification of the document." + }, + summary: { + type: "string", + description: "Concise human-readable summary of the document." + }, + extracted_data: { + type: "object", + properties: { + institution_name: { type: [ "string", "null" ] }, + statement_period_start: { type: [ "string", "null" ], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "YYYY-MM-DD or null" }, + statement_period_end: { type: [ "string", "null" ], pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "YYYY-MM-DD or null" }, + transaction_count: { type: [ "integer", "null" ] }, + opening_balance: { type: [ "number", "null" ] }, + closing_balance: { type: [ "number", "null" ] }, + currency: { type: [ "string", "null" ] }, + account_holder: { type: [ "string", "null" ] } + }, + required: [], + additionalProperties: false + } + }, + required: [ "document_type", "summary", "extracted_data" ], + additionalProperties: false + } + } + end + + def instructions + <<~INSTRUCTIONS + You analyze financial documents. For the attached PDF, classify the document type, + summarize it, and extract key metadata. Return the result via the report_document_analysis tool. + + Classification options: + - bank_statement: bank account statements (incl. mobile money / digital wallets) + - credit_card_statement: credit card statements + - investment_statement: brokerage / investment statements + - financial_document: tax forms, receipts, invoices, financial reports + - contract: legal agreements, loans, terms of service + - other: anything else + + Rules: + - Be factual; only report what is clearly visible + - If a field is unclear/redacted, return null for it + - Do not invent figures or names you cannot read + - For statements with many transactions, return the count rather than enumerating them + INSTRUCTIONS + end + + def extract_tool_input(response) + tool_use = Array(response.content).find { |block| block_type(block) == :tool_use } + raise Provider::Anthropic::Error, "Model did not invoke #{TOOL_NAME}" unless tool_use + + input = block_input(tool_use) + input = JSON.parse(input) if input.is_a?(String) + input + end + + def build_result(parsed) + PdfProcessingResult.new( + summary: parsed["summary"] || parsed[:summary], + document_type: normalize_document_type(parsed["document_type"] || parsed[:document_type]), + extracted_data: parsed["extracted_data"] || parsed[:extracted_data] || {} + ) + end + + def normalize_document_type(doc_type) + return "other" if doc_type.blank? + + normalized = doc_type.to_s.strip.downcase.gsub(/\s+/, "_") + Import::DOCUMENT_TYPES.include?(normalized) ? normalized : "other" + end + + def block_type(block) + raw = block.respond_to?(:type) ? block.type : block[:type] || block["type"] + raw.to_s.to_sym + end + + def block_input(block) + block.respond_to?(:input) ? block.input : (block[:input] || block["input"]) + end + + def usage_hash(raw_usage) + return {} unless raw_usage + { + "input_tokens" => raw_usage.input_tokens.to_i, + "output_tokens" => raw_usage.output_tokens.to_i, + "total_tokens" => raw_usage.input_tokens.to_i + raw_usage.output_tokens.to_i + } + end +end diff --git a/app/models/provider/enable_banking.rb b/app/models/provider/enable_banking.rb index 88df7ec62..da6a2ea06 100644 --- a/app/models/provider/enable_banking.rb +++ b/app/models/provider/enable_banking.rb @@ -39,9 +39,12 @@ class Provider::EnableBanking # @param psu_type [String] "personal" or "business" # @param maximum_consent_validity [Integer, nil] Max consent duration in seconds from ASPSP (nil = use 90 days) # @param language [String, nil] Two-letter language code (e.g. "fr", "en") + # @param auth_method [String, nil] Name of a specific authentication method to use (from the ASPSP's + # auth_methods list). Required to drive DECOUPLED/EMBEDDED banks that expose several methods; when nil + # Enable Banking falls back to the ASPSP's default method. # @return [Hash] Contains :url and :authorization_id def start_authorization(aspsp_name:, aspsp_country:, redirect_url:, state: nil, - psu_type: "personal", maximum_consent_validity: nil, language: nil) + psu_type: "personal", maximum_consent_validity: nil, language: nil, auth_method: nil) max_seconds = maximum_consent_validity ? [ maximum_consent_validity, 1 ].max : 90.days.to_i valid_until = [ Time.current + max_seconds.seconds, Time.current + 90.days ].min @@ -60,6 +63,7 @@ class Provider::EnableBanking psu_type: psu_type } body[:language] = language if language.present? + body[:auth_method] = auth_method if auth_method.present? body = body.compact response = self.class.post( diff --git a/app/models/sure_import.rb b/app/models/sure_import.rb index c738e6156..096b3771d 100644 --- a/app/models/sure_import.rb +++ b/app/models/sure_import.rb @@ -129,16 +129,28 @@ class SureImport < Import self.class.dry_run_totals_from_ndjson(ndjson_blob_string) end - def import! + def import!(import_session: nil) sync_ndjson_counts! before_counts = readback_count_snapshot - importer = Family::DataImporter.new(family, ndjson_blob_string) + importer = Family::DataImporter.new(family, ndjson_blob_string, import_session: import_session, import: self) result = importer.import! - result[:accounts].each { |account| accounts << account } - result[:entries].each { |entry| entries << entry } + Import.transaction do + result[:accounts].each { |account| account.save! if account.new_record? } + result[:entries].each { |entry| entry.save! if entry.new_record? } + + account_ids = result[:accounts].filter_map(&:id) + entry_ids = result[:entries].filter_map(&:id) + existing_account_ids = accounts.where(id: account_ids).pluck(:id) + existing_entry_ids = entries.where(id: entry_ids).pluck(:id) + + accounts.concat(result[:accounts].reject { |account| existing_account_ids.include?(account.id) }) + entries.concat(result[:entries].reject { |entry| existing_entry_ids.include?(entry.id) }) + update!(summary: result[:summary]) if has_attribute?(:summary) + end record_readback_verification!(before_counts:) + result rescue => error record_failed_readback_verification!(before_counts:, error:) raise diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb index 6b55b971e..647f2e91b 100644 --- a/app/views/budget_categories/_budget_category.html.erb +++ b/app/views/budget_categories/_budget_category.html.erb @@ -38,7 +38,7 @@ <%# Progress Bar %>
- <% bar_color = budget_category.over_budget? ? "bg-red-500" : (budget_category.near_limit? ? "bg-yellow-500" : "bg-green-500") %> + <% bar_color = budget_category.over_budget? ? "bg-destructive" : (budget_category.near_limit? ? "bg-warning" : "bg-success") %>
@@ -67,8 +67,7 @@
<%= t("reports.budget_performance.suggested_daily", amount: daily_info[:amount].format, - days: daily_info[:days_remaining]) - %> + days: daily_info[:days_remaining]) %>
<% end %> <% end %> diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index 067286e24..9099fd215 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -20,7 +20,9 @@ data: { turbo_frame: "category_dropdown" }, class: "flex w-full items-center gap-1.5 cursor-pointer focus:outline-none" do %> - <%= icon("check") if is_selected %> + + <% if is_selected %><%= icon("check") %><% end %> + <% if category.subcategory? %> <%= icon("corner-down-right", size: "sm") %> diff --git a/app/views/layouts/auth.html.erb b/app/views/layouts/auth.html.erb index 271abef83..54d0adf51 100644 --- a/app/views/layouts/auth.html.erb +++ b/app/views/layouts/auth.html.erb @@ -7,29 +7,25 @@ <%= image_tag "logomark.svg", class: "w-16 mb-6" %>
- <% if (controller_name == "sessions" && action_name == "new") || (controller_name == "registrations" && action_name == "new") %> -
+ <% if controller_name.in?(%w[sessions registrations]) && action_name.in?(%w[new create]) %> + <%# Determine the active tab from the controller, not current_page?: + a failed POST re-renders :new from #create, where the request + path is the form target (not /sessions/new), so current_page? + would mis-highlight the switch. %> + <% on_sign_in = controller_name == "sessions" %> +
<%= link_to new_session_path, - class: "w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{current_page?(new_session_path) ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}" do %> + class: "w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{on_sign_in ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}" do %> <%= t("layouts.auth.sign_in") %> <% end %> <%= link_to new_registration_path, - class: "w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{!current_page?(new_session_path) ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}" do %> + class: "w-1/2 px-2 py-1 rounded-md text-sm text-center font-medium #{!on_sign_in ? 'bg-surface shadow-sm text-primary' : 'text-secondary'}" do %> <%= t("layouts.auth.sign_up") %> <% end %>
<% end %> - <% if controller_name == "sessions" %> - - <% elsif controller_name == "registrations" %> - - <% end %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index bc1022168..af786a77b 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -29,7 +29,14 @@ <% end %> -
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> +<%= turbo_frame_tag "dashboard_sections", target: "_top" do %> + <% if accessible_accounts.any? %> +
+ <%= render UI::PeriodPicker.new(selected: @period, url: root_path, frame: "dashboard_sections") %> +
+ <% end %> + +
gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections"> <% if accessible_accounts.any? %> <% @dashboard_sections.each do |section| %> <% next unless section[:visible] %> @@ -96,4 +103,5 @@ <%= render "pages/dashboard/no_accounts_graph_placeholder" %> <% end %> -
+
+<% end %> diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index 201e0c8b9..2b91dbc72 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -1,14 +1,5 @@ <%# locals: (sankey_data:, period:) %>
-
- <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> - <%= form.select :period, - Period.as_options, - { selected: period.key }, - data: { "auto-submit-form-target": "auto" }, - class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> - <% end %> -
<% if sankey_data[:links].present? %>
<%= render "pages/dashboard/cashflow_sankey_chart", sankey_data: sankey_data %> diff --git a/app/views/pages/dashboard/_investment_summary.html.erb b/app/views/pages/dashboard/_investment_summary.html.erb index 8509bfe08..b38c2f901 100644 --- a/app/views/pages/dashboard/_investment_summary.html.erb +++ b/app/views/pages/dashboard/_investment_summary.html.erb @@ -97,7 +97,7 @@

<%= t(".contributions") %>

-

<%= format_money(totals.contributions) %>

+

<%= format_money(totals.contributions) %>

@@ -106,7 +106,7 @@

<%= t(".withdrawals") %>

-

<%= format_money(totals.withdrawals) %>

+

<%= format_money(totals.withdrawals) %>

diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb index 1a5f81d68..c9696f1ab 100644 --- a/app/views/pages/dashboard/_net_worth_chart.html.erb +++ b/app/views/pages/dashboard/_net_worth_chart.html.erb @@ -13,14 +13,6 @@

<%= t(".data_not_available") %>

<% end %>
- - <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> - <%= form.select :period, - Period.as_options, - { selected: period.key }, - data: { "auto-submit-form-target": "auto" }, - class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> - <% end %> <% if series.any? %> diff --git a/app/views/pages/dashboard/_outflows_donut.html.erb b/app/views/pages/dashboard/_outflows_donut.html.erb index 633689980..2e430da53 100644 --- a/app/views/pages/dashboard/_outflows_donut.html.erb +++ b/app/views/pages/dashboard/_outflows_donut.html.erb @@ -1,15 +1,5 @@ <%# locals: (outflows_data:, period:) %>
-
- <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %> - <%= form.select :period, - Period.as_options, - { selected: period.key }, - data: { "auto-submit-form-target": "auto" }, - class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %> - <% end %> -
-
diff --git a/app/views/reports/_breakdown_table.html.erb b/app/views/reports/_breakdown_table.html.erb index ff3141557..95fbfab1b 100644 --- a/app/views/reports/_breakdown_table.html.erb +++ b/app/views/reports/_breakdown_table.html.erb @@ -2,7 +2,7 @@ <%# Local variables: groups, total, type (:income or :expense), amount_sort_params, current_sort_by, current_sort_direction %> <% - color_class = type == :income ? "text-success" : "text-destructive" + color_class = type == :income ? "text-success" : "text-primary" icon_name = type == :income ? "trending-up" : "trending-down" title_key = type == :income ? "reports.transactions_breakdown.table.income" : "reports.transactions_breakdown.table.expense" %> diff --git a/app/views/reports/_net_worth.html.erb b/app/views/reports/_net_worth.html.erb index 492d636b6..93f979983 100644 --- a/app/views/reports/_net_worth.html.erb +++ b/app/views/reports/_net_worth.html.erb @@ -29,9 +29,9 @@

<%= t("reports.net_worth.assets_vs_liabilities") %>

- <%= net_worth_metrics[:total_assets].format %> + <%= net_worth_metrics[:total_assets].format %> - - <%= net_worth_metrics[:total_liabilities].format %> + <%= net_worth_metrics[:total_liabilities].format %>
@@ -52,7 +52,7 @@ <% net_worth_metrics[:asset_groups].each_with_index do |group, idx| %> "> <%= group[:name] %> - <%= group[:total].format %> + <%= group[:total].format %> <% end %> <% if net_worth_metrics[:asset_groups].empty? %> @@ -79,7 +79,7 @@ <% net_worth_metrics[:liability_groups].each_with_index do |group, idx| %> "> <%= group[:name] %> - <%= group[:total].format %> + <%= group[:total].format %> <% end %> <% if net_worth_metrics[:liability_groups].empty? %> diff --git a/app/views/reports/_trends_insights.html.erb b/app/views/reports/_trends_insights.html.erb index f3106ea60..c74ab704f 100644 --- a/app/views/reports/_trends_insights.html.erb +++ b/app/views/reports/_trends_insights.html.erb @@ -66,7 +66,7 @@

<%= t("reports.trends.avg_monthly_expenses") %>

-

+

<%= Money.new(avg_expenses, Current.family.currency).format %>

diff --git a/app/views/settings/llm_usages/show.html.erb b/app/views/settings/llm_usages/show.html.erb index 39929a55b..d0b8e5967 100644 --- a/app/views/settings/llm_usages/show.html.erb +++ b/app/views/settings/llm_usages/show.html.erb @@ -103,7 +103,7 @@

<%= t(".recent_usage") %>

-
+
"> <% if @llm_usages.any? %> diff --git a/config/locales/doorkepper.de.yml b/config/locales/doorkeeper.de.yml similarity index 100% rename from config/locales/doorkepper.de.yml rename to config/locales/doorkeeper.de.yml diff --git a/config/locales/views/account_sharings/de.yml b/config/locales/views/account_sharings/de.yml new file mode 100644 index 000000000..f770ce305 --- /dev/null +++ b/config/locales/views/account_sharings/de.yml @@ -0,0 +1,29 @@ +--- +de: + account_sharings: + show: + title: Kontofreigabe + subtitle: Legen Sie fest, wer dieses Konto sehen und damit interagieren kann + member: Mitglied + permission: Berechtigung + shared: Freigegeben + no_members: Keine weiteren Mitglieder in Ihrem %{moniker} zum Teilen + permissions: + full_control: Volle Kontrolle + full_control_description: Kann Transaktionen ansehen, bearbeiten und verwalten + read_write: Kann kommentieren + read_write_description: Kann kategorisieren, mit Tags versehen und Notizen hinzufügen + read_only: Nur ansehen + read_only_description: Kann nur Kontodaten ansehen + save: Freigabeeinstellungen speichern + owner_label: "Eigentümer: %{name}" + shared_with_count: + one: Mit 1 Mitglied geteilt + other: "Mit %{count} Mitgliedern geteilt" + include_in_finances: In meine Budgets & Berichte einbeziehen + exclude_from_finances: Von meinen Budgets & Berichten ausschließen + finance_toggle_description: Dieses Konto in Ihrem Nettovermögen, Budgets und Berichten berücksichtigen + update: + success: Freigabeeinstellungen aktualisiert + not_owner: Nur der Kontoeigentümer kann die Freigabe verwalten + finance_toggle_success: Einstellung zur Finanzberücksichtigung aktualisiert diff --git a/config/locales/views/account_sharings/es.yml b/config/locales/views/account_sharings/es.yml new file mode 100644 index 000000000..c05fe3015 --- /dev/null +++ b/config/locales/views/account_sharings/es.yml @@ -0,0 +1,29 @@ +--- +es: + account_sharings: + show: + title: Compartir cuenta + subtitle: Controla quién puede ver esta cuenta e interactuar con ella + member: Miembro + permission: Permiso + shared: Compartida + no_members: No hay otros miembros en tu %{moniker} con quienes compartir + permissions: + full_control: Control total + full_control_description: Puede ver, editar y gestionar transacciones + read_write: Puede anotar + read_write_description: Puede categorizar, etiquetar y añadir notas + read_only: Solo lectura + read_only_description: Solo puede ver los datos de la cuenta + save: Guardar ajustes de uso compartido + owner_label: "Propietario: %{name}" + shared_with_count: + one: Compartida con 1 miembro + other: "Compartida con %{count} miembros" + include_in_finances: Incluir en mis presupuestos e informes + exclude_from_finances: Excluir de mis presupuestos e informes + finance_toggle_description: Tener en cuenta esta cuenta en tu patrimonio neto, presupuestos e informes + update: + success: Ajustes de uso compartido actualizados + not_owner: Solo el propietario de la cuenta puede gestionar el uso compartido + finance_toggle_success: Preferencia de inclusión financiera actualizada diff --git a/config/locales/views/account_statements/de.yml b/config/locales/views/account_statements/de.yml new file mode 100644 index 000000000..52d629ca4 --- /dev/null +++ b/config/locales/views/account_statements/de.yml @@ -0,0 +1,116 @@ +--- +de: + account_statements: + account_tab: + coverage_title: Kontoauszug-Abdeckung + coverage_description: Historische Monate, die durch hochgeladene Kontoauszüge und Saldoprüfungen belegt sind. + coverage_range: "%{start} - %{end}" + empty: Diesem Konto sind noch keine Kontoauszüge zugeordnet. + open_inbox: Posteingang + statements_title: Kontoauszüge + year_label: Abdeckungsjahr + balance: + unknown: Unbekannt + coverage: + status: + ambiguous: Mehrdeutig + covered: Abgedeckt + duplicate: Duplikat + mismatched: Abweichend + missing: Fehlend + not_expected: Nicht erwartet + create: + duplicates: + one: 1 doppelter Kontoauszug wurde übersprungen. + other: "%{count} doppelte Kontoauszüge wurden übersprungen." + invalid_file_type: Laden Sie einen Kontoauszug als PDF, CSV oder XLSX innerhalb der Größenbeschränkung hoch. + no_files: Wählen Sie mindestens eine Kontoauszugsdatei aus. + success: + one: 1 Kontoauszug hochgeladen. + other: "%{count} Kontoauszüge hochgeladen." + destroy: + failure: Kontoauszug konnte nicht gelöscht werden. + success: Kontoauszug gelöscht. + form: + account_upload: Kontoauszug hochladen + files_hint: PDF, CSV oder XLSX. Max. %{max_size} MB pro Datei. + files_label: Kontoauszugsdateien + inbox_upload: Hochladen + index: + account_label: Konto + confidence: "%{confidence} Übereinstimmung" + empty_linked: Noch keine verknüpften Kontoauszüge. + empty_unmatched: Der Kontoauszug-Posteingang ist leer. + leave_unmatched: Nicht zugeordnet lassen + linked_title: Verknüpfte Kontoauszüge + no_suggestion: Kein Vorschlag + storage_used: Belegter Speicher + title: Kontoauszug-Tresor + unmatched_title: Nicht zugeordneter Posteingang + upload_description: Laden Sie Kontoauszüge in den Posteingang hoch oder wählen Sie ein Konto zur sofortigen Verknüpfung. + upload_title: Kontoauszüge hochladen + link: + no_account: Wählen Sie ein Konto aus, bevor Sie diesen Kontoauszug verknüpfen. + success: Kontoauszug mit %{account} verknüpft. + period: + unknown: Zeitraum unbekannt + reconciliation: + checks: + closing_balance: Schlusssaldo + opening_balance: Anfangssaldo + period_movement: Bewegung im Zeitraum + unknown_check: Unbekannte Prüfung + matched: Übereinstimmend + mismatched: Abweichend + unavailable: Nicht geprüft + reject: + success: Kontoauszug-Zuordnung abgelehnt. + show: + account_label: Konto + account_last4_hint: Letzte vier Ziffern des Kontos + account_name_hint: Hinweis zum Kontonamen + closing_balance: Schlusssaldo + currency: Währung + delete: Löschen + difference: Differenz + download: Herunterladen + institution_name_hint: Hinweis zum Institut + ledger_amount: Sure-Buchung + linked_to: Verknüpft mit %{account}. + linking_title: Kontoverknüpfung + link_suggestion: Verknüpfungsvorschlag + metadata_title: Kontoauszug-Metadaten + no_suggestion: Noch kein Kontovorschlag. + opening_balance: Anfangssaldo + period_end_on: Ende des Zeitraums + period_start_on: Beginn des Zeitraums + reconciliation_title: Abgleich + reconciliation_unavailable: Fügen Sie einen Kontoauszugszeitraum und einen Anfangs- oder Schlusssaldo hinzu und stellen Sie sicher, dass Sure für diese Daten einen Saldoverlauf hat. + reject: Ablehnen + save: Kontoauszug speichern + statement_amount: Kontoauszug + suggested_account: Vorgeschlagenes Konto ist %{account} (%{confidence} Konfidenz). + title: Kontoauszug + unlink: Verknüpfung aufheben + unmatched_account: Nicht zugeordneter Posteingang + unknown_value: Unbekannt + status: + linked: Verknüpft + rejected: Abgelehnt + unmatched: Nicht zugeordnet + table: + account: Konto + actions: Aktionen + download: Herunterladen + file: Datei + link_suggestion: Verknüpfungsvorschlag + period: Zeitraum + reconciliation: Abgleich + reject: Vorschlag ablehnen + suggestion: Vorschlag + unlink: Verknüpfung aufheben + view: Ansehen + unlink: + success: Kontoauszug zurück in den nicht zugeordneten Posteingang verschoben. + update: + success: Kontoauszug aktualisiert. diff --git a/config/locales/views/account_statements/es.yml b/config/locales/views/account_statements/es.yml new file mode 100644 index 000000000..3486f844d --- /dev/null +++ b/config/locales/views/account_statements/es.yml @@ -0,0 +1,116 @@ +--- +es: + account_statements: + account_tab: + coverage_title: Cobertura de extractos + coverage_description: Meses históricos respaldados por extractos subidos y comprobaciones de saldo. + coverage_range: "%{start} - %{end}" + empty: Aún no hay extractos vinculados a esta cuenta. + open_inbox: Bandeja de entrada + statements_title: Extractos + year_label: Año de cobertura + balance: + unknown: Desconocido + coverage: + status: + ambiguous: Ambiguo + covered: Cubierto + duplicate: Duplicado + mismatched: No coincide + missing: Faltante + not_expected: No esperado + create: + duplicates: + one: Se omitió 1 extracto duplicado. + other: "Se omitieron %{count} extractos duplicados." + invalid_file_type: Sube un extracto en PDF, CSV o XLSX que no supere el límite de tamaño. + no_files: Selecciona al menos un archivo de extracto. + success: + one: 1 extracto subido. + other: "%{count} extractos subidos." + destroy: + failure: No se pudo eliminar el extracto. + success: Extracto eliminado. + form: + account_upload: Subir extracto + files_hint: PDF, CSV o XLSX. Máximo %{max_size} MB por archivo. + files_label: Archivos de extracto + inbox_upload: Subir + index: + account_label: Cuenta + confidence: "Coincidencia del %{confidence}" + empty_linked: Aún no hay extractos vinculados. + empty_unmatched: La bandeja de entrada de extractos está vacía. + leave_unmatched: Dejar sin asignar + linked_title: Extractos vinculados + no_suggestion: Sin sugerencia + storage_used: Almacenamiento usado + title: Bóveda de extractos + unmatched_title: Bandeja de entrada sin asignar + upload_description: Sube extractos a la bandeja de entrada o elige una cuenta para vincularlos de inmediato. + upload_title: Subir extractos + link: + no_account: Elige una cuenta antes de vincular este extracto. + success: Extracto vinculado a %{account}. + period: + unknown: Periodo desconocido + reconciliation: + checks: + closing_balance: Saldo de cierre + opening_balance: Saldo de apertura + period_movement: Movimiento del periodo + unknown_check: Comprobación desconocida + matched: Coincide + mismatched: No coincide + unavailable: Sin comprobar + reject: + success: Coincidencia de extracto rechazada. + show: + account_label: Cuenta + account_last4_hint: Últimos cuatro dígitos de la cuenta + account_name_hint: Pista del nombre de la cuenta + closing_balance: Saldo de cierre + currency: Moneda + delete: Eliminar + difference: Diferencia + download: Descargar + institution_name_hint: Pista de la entidad + ledger_amount: Registro de Sure + linked_to: Vinculado a %{account}. + linking_title: Vínculo de cuenta + link_suggestion: Sugerencia de vínculo + metadata_title: Metadatos del extracto + no_suggestion: Aún no hay sugerencia de cuenta. + opening_balance: Saldo de apertura + period_end_on: Fin del periodo + period_start_on: Inicio del periodo + reconciliation_title: Conciliación + reconciliation_unavailable: Añade un periodo de extracto y un saldo de apertura o cierre, y asegúrate de que Sure tenga historial de saldos para esas fechas. + reject: Rechazar + save: Guardar extracto + statement_amount: Extracto + suggested_account: La cuenta sugerida es %{account} (confianza del %{confidence}). + title: Extracto + unlink: Desvincular + unmatched_account: Bandeja de entrada sin asignar + unknown_value: Desconocido + status: + linked: Vinculado + rejected: Rechazado + unmatched: Sin asignar + table: + account: Cuenta + actions: Acciones + download: Descargar + file: Archivo + link_suggestion: Sugerencia de vínculo + period: Periodo + reconciliation: Conciliación + reject: Rechazar sugerencia + suggestion: Sugerencia + unlink: Desvincular + view: Ver + unlink: + success: Extracto devuelto a la bandeja de entrada sin asignar. + update: + success: Extracto actualizado. diff --git a/config/locales/views/binance_items/de.yml b/config/locales/views/binance_items/de.yml new file mode 100644 index 000000000..52c731826 --- /dev/null +++ b/config/locales/views/binance_items/de.yml @@ -0,0 +1,75 @@ +--- +de: + binance_items: + create: + default_name: Binance + success: Erfolgreich mit Binance verbunden! Ihr Konto wird synchronisiert. + update: + success: Binance-Konfiguration wurde erfolgreich aktualisiert. + destroy: + success: Binance-Verbindung wurde zur Löschung vorgemerkt. + setup_accounts: + title: Binance-Konto importieren + subtitle: Wählen Sie die zu verfolgenden Portfolios + instructions: Wählen Sie die Binance-Portfolios aus, die Sie importieren möchten. Es werden nur Portfolios mit Salden angezeigt. + no_accounts: Alle Konten wurden bereits importiert. + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Konto importiert" + other: "%{count} Konten importiert" + none_selected: Keine Konten ausgewählt + no_accounts: Keine Konten zum Import + binance_item: + provider_name: Binance + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + update_credentials: Zugangsdaten aktualisieren + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihr Binance-Portfolio erscheint hier nach der Synchronisierung. + setup_needed: Konto bereit zum Import + setup_description: Wählen Sie die Binance-Portfolios, die Sie verfolgen möchten. + setup_action: Konto importieren + import_accounts_menu: Konto importieren + stale_rate_warning: "Der Saldo ist ein Näherungswert – der genaue Wechselkurs für %{date} war nicht verfügbar. Er wird bei der nächsten Synchronisierung aktualisiert." + select_existing_account: + title: Binance-Konto verknüpfen + no_accounts_found: Keine Binance-Konten gefunden. + wait_for_sync: Warten Sie, bis Binance die Synchronisierung abgeschlossen hat + check_provider_health: Prüfen Sie, ob Ihre Binance-API-Zugangsdaten gültig sind + currently_linked_to: "Aktuell verknüpft mit: %{account_name}" + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Binance-Konto verknüpft + errors: + only_manual: Nur manuelle Konten können mit Binance verknüpft werden + invalid_binance_account: Ungültiges Binance-Konto + binance_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige API-Zugangsdaten. Bitte überprüfen Sie Ihren API-Schlüssel und Ihr Secret. + importing_accounts: Konten werden von Binance importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/binance_items/es.yml b/config/locales/views/binance_items/es.yml new file mode 100644 index 000000000..2b83aa0bd --- /dev/null +++ b/config/locales/views/binance_items/es.yml @@ -0,0 +1,75 @@ +--- +es: + binance_items: + create: + default_name: Binance + success: ¡Conexión con Binance establecida con éxito! Tu cuenta se está sincronizando. + update: + success: Configuración de Binance actualizada correctamente. + destroy: + success: Conexión de Binance programada para su eliminación. + setup_accounts: + title: Importar cuenta de Binance + subtitle: Selecciona qué carteras quieres seguir + instructions: Selecciona las carteras de Binance que quieres importar. Solo se muestran las carteras con saldos. + no_accounts: Se han importado todas las cuentas. + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cuenta" + other: "Se han importado %{count} cuentas" + none_selected: No se ha seleccionado ninguna cuenta + no_accounts: No hay cuentas para importar + binance_item: + provider_name: Binance + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + update_credentials: Actualizar credenciales + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tu cartera de Binance aparecerá aquí después de la sincronización. + setup_needed: Cuenta lista para importar + setup_description: Selecciona qué carteras de Binance quieres seguir. + setup_action: Importar cuenta + import_accounts_menu: Importar cuenta + stale_rate_warning: "El saldo es aproximado: el tipo de cambio exacto para %{date} no estaba disponible. Se actualizará en la próxima sincronización." + select_existing_account: + title: Vincular cuenta de Binance + no_accounts_found: No se han encontrado cuentas de Binance. + wait_for_sync: Espera a que Binance termine de sincronizar + check_provider_health: Comprueba que tus credenciales de la API de Binance sean válidas + currently_linked_to: "Actualmente vinculada a: %{account_name}" + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Binance + errors: + only_manual: Solo las cuentas manuales pueden vincularse a Binance + invalid_binance_account: Cuenta de Binance no válida + binance_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de la API no válidas. Comprueba tu clave de API y tu secreto. + importing_accounts: Importando cuentas desde Binance... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando los datos de la cuenta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/brex_items/de.yml b/config/locales/views/brex_items/de.yml new file mode 100644 index 000000000..a724c2cf5 --- /dev/null +++ b/config/locales/views/brex_items/de.yml @@ -0,0 +1,277 @@ +--- +de: + brex_items: + default_connection_name: Brex-Verbindung + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Bargeld + card: Karte + statuses: + ACTIVE: Aktiv + active: Aktiv + CLOSED: Geschlossen + closed: Geschlossen + frozen: Eingefroren + FROZEN: Eingefroren + create: + success: Brex-Verbindung erfolgreich erstellt + default_card_name: Brex-Karte + default_cash_name: "Brex Cash %{id}" + destroy: + success: Brex-Verbindung entfernt + index: + title: Brex-Verbindungen + institution_summary: + none: Keine Institute verbunden + one: "%{name}" + count: + one: "%{count} Institut" + other: "%{count} Institute" + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_setup: "%{synced} synchronisiert, %{pending} müssen eingerichtet werden" + api_error: + common_issues: "Häufige Probleme:" + expired_credentials: Erstellen Sie ein neues API-Token bei Brex. + expired_credentials_label: "Abgelaufene Zugangsdaten:" + heading: Verbindung zu Brex nicht möglich + invalid_token: Überprüfen Sie Ihr API-Token in den Anbietereinstellungen. + invalid_token_label: "Ungültiges API-Token:" + network: Überprüfen Sie Ihre Internetverbindung. + network_label: "Netzwerkproblem:" + permissions: Stellen Sie sicher, dass Ihr Token über die erforderlichen schreibgeschützten Konto- und Transaktions-Scopes verfügt. + permissions_label: "Unzureichende Berechtigungen:" + service: Die Brex-API ist möglicherweise vorübergehend nicht verfügbar. + service_label: "Dienst nicht verfügbar:" + settings_link: Anbietereinstellungen prüfen + title: Brex-Verbindungsfehler + errors: + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + entries: + default_name: Brex-Transaktion + loading: + loading_message: Brex-Konten werden geladen... + loading_title: Wird geladen + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Fehler: %{message}" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + invalid_account_type: Nicht unterstützter Brex-Kontotyp + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Bitte wählen Sie mindestens ein Konto aus + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + partial_invalid: "%{created_count} Konto(en) erfolgreich verknüpft, %{already_linked_count} Konto(en) waren bereits verknüpft, %{invalid_count} Konto(en) hatten ungültige Namen" + partial_success: "%{created_count} Konto(en) erfolgreich verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft: %{already_linked_names}" + select_connection: Wählen Sie eine Brex-Verbindung aus, bevor Sie Konten verknüpfen. + success: + one: "%{count} Konto erfolgreich verknüpft" + other: "%{count} Konten erfolgreich verknüpft" + brex_item: + accounts_need_setup: Konten müssen eingerichtet werden + delete: Verbindung löschen + deletion_in_progress: Löschung läuft... + error: Fehler + no_accounts_description: Diese Verbindung hat noch keine verknüpften Konten. + no_accounts_title: Keine Konten + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Brex-Konten." + setup_needed: Neue Konten bereit zur Einrichtung + status: "Vor %{timestamp} synchronisiert" + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + syncing: Synchronisiere... + total: Gesamt + unlinked: Nicht verknüpft + provider_panel: + accounts_link: Konten + add_connection: Brex-Verbindung hinzufügen + base_url_label: Basis-URL (optional) + base_url_placeholder: https://api.brex.com + configured_html: "Konfiguriert und einsatzbereit. Besuchen Sie den Tab %{accounts_link}, um Konten zu verwalten und einzurichten." + connection_name_label: Verbindungsname + connection_name_placeholder: Geschäftskonto + default_connection_name: Brex-Verbindung + disconnect_label: "%{name} trennen" + disconnect_confirm: "%{name} trennen?" + encryption_warning: + title: Datenbankverschlüsselung ist nicht konfiguriert + message: Konfigurieren Sie die Active Record-Verschlüsselungsschlüssel, bevor Sie Brex-Tokens in der Produktion hinzufügen. Ohne Verschlüsselungsschlüssel speichert Sure die Brex-Anbieter-Zugangsdaten und Snapshots im Klartext, wie bei anderen Anbieterdatensätzen. + instructions: + copy_token_html: "Kopieren Sie das Token und fügen Sie es unten als benannte Verbindung hinzu. Sure speichert das Token nur zur Synchronisierung dieser Familie." + create_token: "Erstellen Sie ein API-Token mit diesen schreibgeschützten Scopes: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Gehen Sie zu den Brex-Entwickler-/API-Token-Einstellungen für das Unternehmen, das Sie verbinden möchten + sign_in_html: "Besuchen Sie %{link} und melden Sie sich bei dem Konto an, das Sie verbinden möchten" + keep_token_placeholder: Leer lassen, um das aktuelle Token beizubehalten + not_configured: Nicht konfiguriert + sandbox_note_html: "Verwenden Sie für jedes Brex-Unternehmen/API-Token, das Sie synchronisieren möchten, eine separate benannte Verbindung. Lassen Sie die Basis-URL für die Produktion leer. Staging ist auf von Brex genehmigte Tests beschränkt und funktioniert nicht mit Kunden-Tokens." + setup_accounts: Konten einrichten + setup_title: "Einrichtungsanweisungen:" + sync: Synchronisieren + token_label: Token + token_placeholder: Token hier einfügen + update_connection: Verbindung aktualisieren + provider_connection: + default_description: Mit Ihrem Brex-Konto verbinden + default_name: Brex + description: "Verbinden mit %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: Konten ausgewählt + api_error: "API-Fehler: %{message}" + cancel: Abbrechen + configure_name_in_brex: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Brex + description: Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten. + link_accounts: Ausgewählte Konten verknüpfen + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration. + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + no_credentials_configured: Bitte konfigurieren Sie zuerst Ihr Brex-API-Token in den Anbietereinstellungen. + no_name_placeholder: "(Kein Name)" + select_connection: Wählen Sie in den Anbietereinstellungen eine Brex-Verbindung aus. + title: Brex-Konten auswählen + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + select_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + all_accounts_already_linked: Alle Brex-Konten sind bereits verknüpft + api_error: "API-Fehler: %{message}" + cancel: Abbrechen + configure_name_in_brex: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Brex + description: Wählen Sie ein Brex-Konto aus, das mit diesem Konto verknüpft werden soll. Transaktionen werden automatisch synchronisiert und dedupliziert. + link_account: Konto verknüpfen + no_account_specified: Kein Konto angegeben + no_accounts_found: Keine Brex-Konten gefunden. Bitte überprüfen Sie Ihre API-Token-Konfiguration. + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + no_credentials_configured: Bitte konfigurieren Sie zuerst Ihr Brex-API-Token in den Anbietereinstellungen. + no_name_placeholder: "(Kein Name)" + select_connection: Wählen Sie in den Anbietereinstellungen eine Brex-Verbindung aus. + title: "%{account_name} mit Brex verknüpfen" + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut. + setup_required: + description: Bevor Sie Brex-Konten verknüpfen können, müssen Sie Ihr Brex-API-Token konfigurieren. + heading: API-Token nicht konfiguriert + settings_link: Zu den Anbietereinstellungen + setup_steps: "Einrichtungsschritte:" + steps: + enter_token: Geben Sie Ihr Brex-API-Token ein + find_section_html: "Suchen Sie den Bereich Brex" + open_settings_html: "Gehen Sie zu Einstellungen > Anbieter" + return_to_link: Kehren Sie hierher zurück, um Ihre Konten zu verknüpfen + title: Brex-Einrichtung erforderlich + subtype_select: + placeholder: + subtype: Untertyp auswählen + type: Typ auswählen + link_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + api_error: "API-Fehler: %{message}" + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + missing_parameters: Erforderliche Parameter fehlen + no_account_specified: Kein Konto angegeben + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + provider_account_already_linked: Dieses Brex-Konto ist bereits mit einem anderen Konto verknüpft + provider_account_not_found: Brex-Konto nicht gefunden + select_connection: Wählen Sie eine Brex-Verbindung aus, bevor Sie Konten verknüpfen. + success: "%{account_name} erfolgreich mit Brex verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Brex-Konten wurden bereits eingerichtet." + api_error: "API-Fehler: %{message}" + fetch_failed: "Konten konnten nicht abgerufen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_api_token: Brex-API-Token nicht gefunden. Bitte konfigurieren Sie es in den Anbietereinstellungen. + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Investmentkonto + loan: Darlehen oder Hypothek + other_asset: Sonstiger Vermögenswert + subtype_labels: + depository: "Kontountertyp:" + credit_card: "" + investment: "Investmenttyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstige Vermögenswerte sind keine zusätzlichen Optionen erforderlich." + subtypes: + depository: + checking: Girokonto + savings: Sparkonto + hsa: Gesundheitssparkonto + cd: Festgeld + money_market: Geldmarktkonto + investment: + brokerage: Brokerage + pension: Rente + retirement: Altersvorsorge + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529-Plan" + hsa: Gesundheitssparkonto + mutual_fund: Investmentfonds + ira: Traditionelle IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Hypothek + student: Studienkredit + auto: Autokredit + other: Sonstiges Darlehen + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wählen Sie den richtigen Kontotyp für jedes Brex-Konto:" + create_accounts: Konten erstellen + creating_accounts: Konten werden erstellt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wählen Sie die richtigen Kontotypen für Ihre importierten Konten + sync_start_date_help: Wählen Sie aus, wie weit zurück Sie den Transaktionsverlauf synchronisieren möchten. Maximal 3 Jahre Verlauf verfügbar. + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: Richten Sie Ihre Brex-Konten ein + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden: %{error}" + creation_failed_count: "%{count} Konto(en) konnten nicht erstellt werden." + no_accounts: "Keine Konten zum Einrichten." + partial_skipped: "%{created_count} Konto(en) erfolgreich erstellt; %{skipped_count} Konto(en) wurden übersprungen." + partial_success: "%{created_count} Konto(en) erfolgreich erstellt, aber %{failed_count} Konto(en) sind fehlgeschlagen." + success: "%{count} Konto(en) erfolgreich erstellt." + unexpected_error: Ein unerwarteter Fehler ist aufgetreten. + sync: + success: Synchronisierung gestartet + syncer: + account_processing_failed: + one: "%{count} Brex-Konto ist bei der Verarbeitung fehlgeschlagen." + other: "%{count} Brex-Konten sind bei der Verarbeitung fehlgeschlagen." + account_sync_failed: + one: "%{count} Brex-Kontosynchronisierung konnte nicht geplant werden." + other: "%{count} Brex-Kontosynchronisierungen konnten nicht geplant werden." + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden..." + other: "%{count} Konten müssen eingerichtet werden..." + accounts_failed: + one: "%{count} Brex-Konto konnte nicht importiert werden." + other: "%{count} Brex-Konten konnten nicht importiert werden." + calculating_balances: Salden werden berechnet... + checking_account_configuration: Kontokonfiguration wird geprüft... + credentials_invalid: Ungültiges Brex-API-Token oder ungültige Kontoberechtigungen + failed: Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support. + import_failed: Brex-Import fehlgeschlagen. + importing_accounts: Konten werden von Brex importiert... + processing_transactions: Transaktionen werden verarbeitet... + transactions_failed: + one: "%{count} Brex-Konto hatte Fehler beim Transaktionsimport." + other: "%{count} Brex-Konten hatten Fehler beim Transaktionsimport." + update: + success: Brex-Verbindung aktualisiert diff --git a/config/locales/views/brex_items/es.yml b/config/locales/views/brex_items/es.yml new file mode 100644 index 000000000..f2f3949a2 --- /dev/null +++ b/config/locales/views/brex_items/es.yml @@ -0,0 +1,277 @@ +--- +es: + brex_items: + default_connection_name: Conexión de Brex + account_metadata: + provider: Brex + separator: " • " + kinds: + cash: Efectivo + card: Tarjeta + statuses: + ACTIVE: Activa + active: Activa + CLOSED: Cerrada + closed: Cerrada + frozen: Congelada + FROZEN: Congelada + create: + success: Conexión de Brex creada correctamente + default_card_name: Tarjeta Brex + default_cash_name: "Brex Cash %{id}" + destroy: + success: Conexión de Brex eliminada + index: + title: Conexiones de Brex + institution_summary: + none: No hay entidades conectadas + one: "%{name}" + count: + one: "%{count} entidad" + other: "%{count} entidades" + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_setup: "%{synced} sincronizadas, %{pending} necesitan configuración" + api_error: + common_issues: "Problemas habituales:" + expired_credentials: Genera un nuevo token de API en Brex. + expired_credentials_label: "Credenciales caducadas:" + heading: No se puede conectar con Brex + invalid_token: Comprueba tu token de API en los Ajustes del proveedor. + invalid_token_label: "Token de API no válido:" + network: Comprueba tu conexión a internet. + network_label: "Problema de red:" + permissions: Asegúrate de que tu token tenga los ámbitos de solo lectura necesarios para cuentas y transacciones. + permissions_label: "Permisos insuficientes:" + service: Es posible que la API de Brex no esté disponible temporalmente. + service_label: "Servicio caído:" + settings_link: Comprobar los Ajustes del proveedor + title: Error de conexión con Brex + errors: + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + entries: + default_name: Transacción de Brex + loading: + loading_message: Cargando cuentas de Brex... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de la API: %{message}" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + invalid_account_type: Tipo de cuenta de Brex no admitido + link_failed: No se pudieron vincular las cuentas + no_accounts_selected: Selecciona al menos una cuenta + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + partial_invalid: "Se vincularon %{created_count} cuenta(s) correctamente, %{already_linked_count} cuenta(s) ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se vincularon %{created_count} cuenta(s) correctamente. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + select_connection: Elige una conexión de Brex antes de vincular cuentas. + success: + one: "Se ha vinculado %{count} cuenta correctamente" + other: "Se han vinculado %{count} cuentas correctamente" + brex_item: + accounts_need_setup: Las cuentas necesitan configuración + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Brex recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + syncing: Sincronizando... + total: Total + unlinked: Sin vincular + provider_panel: + accounts_link: Cuentas + add_connection: Añadir conexión de Brex + base_url_label: URL base (opcional) + base_url_placeholder: https://api.brex.com + configured_html: "Configurado y listo para usar. Visita la pestaña %{accounts_link} para gestionar y configurar las cuentas." + connection_name_label: Nombre de la conexión + connection_name_placeholder: Cuenta corriente de empresa + default_connection_name: Conexión de Brex + disconnect_label: "Desconectar %{name}" + disconnect_confirm: "¿Desconectar %{name}?" + encryption_warning: + title: El cifrado de la base de datos no está configurado + message: Configura las claves de cifrado de Active Record antes de añadir tokens de Brex en producción. Sin claves de cifrado, Sure almacena las credenciales y las instantáneas del proveedor Brex en texto plano, como ocurre con otros registros de proveedores. + instructions: + copy_token_html: "Copia el token y añádelo abajo como una conexión con nombre. Sure almacena el token únicamente para sincronizar esta familia." + create_token: "Crea un token de API con estos ámbitos de solo lectura: accounts.cash.readonly, accounts.card.readonly, transactions.cash.readonly, transactions.card.readonly" + open_tokens: Ve a los ajustes de tokens de desarrollador/API de Brex de la empresa que quieres conectar + sign_in_html: "Visita %{link} e inicia sesión en la cuenta que quieres conectar" + keep_token_placeholder: Déjalo en blanco para conservar el token actual + not_configured: No configurado + sandbox_note_html: "Usa una conexión con nombre distinta para cada empresa/token de API de Brex que quieras sincronizar. Deja la URL base en blanco para producción. El entorno de staging está limitado a pruebas aprobadas por Brex y no funciona con tokens de clientes." + setup_accounts: Configurar cuentas + setup_title: "Instrucciones de configuración:" + sync: Sincronizar + token_label: Token + token_placeholder: Pega el token aquí + update_connection: Actualizar conexión + provider_connection: + default_description: Conectar a tu cuenta de Brex + default_name: Brex + description: "Conectar mediante %{name}" + name: "Brex - %{name}" + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de la API: %{message}" + cancel: Cancelar + configure_name_in_brex: "No se puede importar: configura el nombre de la cuenta en Brex" + description: Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu token de API. + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + no_credentials_configured: Configura primero tu token de API de Brex en los Ajustes del proveedor. + no_name_placeholder: "(Sin nombre)" + select_connection: Elige una conexión de Brex en los Ajustes del proveedor. + title: Seleccionar cuentas de Brex + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Brex ya están vinculadas + api_error: "Error de la API: %{message}" + cancel: Cancelar + configure_name_in_brex: "No se puede importar: configura el nombre de la cuenta en Brex" + description: Selecciona una cuenta de Brex para vincularla a esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente. + link_account: Vincular cuenta + no_account_specified: No se ha especificado ninguna cuenta + no_accounts_found: No se han encontrado cuentas de Brex. Comprueba la configuración de tu token de API. + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + no_credentials_configured: Configura primero tu token de API de Brex en los Ajustes del proveedor. + no_name_placeholder: "(Sin nombre)" + select_connection: Elige una conexión de Brex en los Ajustes del proveedor. + title: "Vincular %{account_name} con Brex" + unexpected_error: Se ha producido un error inesperado. Inténtalo de nuevo más tarde. + setup_required: + description: Antes de poder vincular cuentas de Brex, debes configurar tu token de API de Brex. + heading: Token de API no configurado + settings_link: Ir a los Ajustes del proveedor + setup_steps: "Pasos de configuración:" + steps: + enter_token: Introduce tu token de API de Brex + find_section_html: "Busca la sección Brex" + open_settings_html: "Ve a Ajustes > Proveedores" + return_to_link: Vuelve aquí para vincular tus cuentas + title: Configuración de Brex necesaria + subtype_select: + placeholder: + subtype: Selecciona el subtipo + type: Selecciona el tipo + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de la API: %{message}" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + missing_parameters: Faltan parámetros obligatorios + no_account_specified: No se ha especificado ninguna cuenta + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + provider_account_already_linked: Esta cuenta de Brex ya está vinculada a otra cuenta + provider_account_not_found: No se ha encontrado la cuenta de Brex + select_connection: Elige una conexión de Brex antes de vincular cuentas. + success: "%{account_name} vinculada correctamente con Brex" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Brex ya se han configurado." + api_error: "Error de la API: %{message}" + fetch_failed: "No se pudieron obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_api_token: No se ha encontrado el token de API de Brex. Configúralo en los Ajustes del proveedor. + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorros + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para Otros activos." + subtypes: + depository: + checking: Cuenta corriente + savings: Cuenta de ahorros + hsa: Cuenta de ahorro para la salud + cd: Certificado de depósito + money_market: Cuenta del mercado monetario + investment: + brokerage: Cuenta de bróker + pension: Pensión + retirement: Jubilación + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "Plan 529" + hsa: Cuenta de ahorro para la salud + mutual_fund: Fondo de inversión + ira: IRA tradicional + roth_ira: Roth IRA + angel: Inversión ángel + loan: + mortgage: Hipoteca + student: Préstamo estudiantil + auto: Préstamo para automóvil + other: Otro préstamo + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Brex:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona hasta qué fecha quieres sincronizar el historial de transacciones. Hay un máximo de 3 años de historial disponible. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Brex + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se ha creado ninguna cuenta." + creation_failed: "No se pudieron crear las cuentas: %{error}" + creation_failed_count: "No se pudieron crear %{count} cuenta(s)." + no_accounts: "No hay cuentas para configurar." + partial_skipped: "Se crearon %{created_count} cuenta(s) correctamente; se omitieron %{skipped_count} cuenta(s)." + partial_success: "Se crearon %{created_count} cuenta(s) correctamente, pero %{failed_count} cuenta(s) fallaron." + success: "Se han creado %{count} cuenta(s) correctamente." + unexpected_error: Se ha producido un error inesperado. + sync: + success: Sincronización iniciada + syncer: + account_processing_failed: + one: "%{count} cuenta de Brex falló durante el procesamiento." + other: "%{count} cuentas de Brex fallaron durante el procesamiento." + account_sync_failed: + one: "No se pudo programar la sincronización de %{count} cuenta de Brex." + other: "No se pudo programar la sincronización de %{count} cuentas de Brex." + accounts_need_setup: + one: "%{count} cuenta necesita configuración..." + other: "%{count} cuentas necesitan configuración..." + accounts_failed: + one: "No se pudo importar %{count} cuenta de Brex." + other: "No se pudieron importar %{count} cuentas de Brex." + calculating_balances: Calculando saldos... + checking_account_configuration: Comprobando la configuración de la cuenta... + credentials_invalid: Token de API de Brex no válido o permisos de cuenta insuficientes + failed: La sincronización ha fallado. Inténtalo de nuevo o ponte en contacto con el soporte. + import_failed: La importación de Brex ha fallado. + importing_accounts: Importando cuentas desde Brex... + processing_transactions: Procesando transacciones... + transactions_failed: + one: "%{count} cuenta de Brex tuvo fallos al importar transacciones." + other: "%{count} cuentas de Brex tuvieron fallos al importar transacciones." + update: + success: Conexión de Brex actualizada diff --git a/config/locales/views/components/en.yml b/config/locales/views/components/en.yml index e0dfca1ab..a8a96ad68 100644 --- a/config/locales/views/components/en.yml +++ b/config/locales/views/components/en.yml @@ -1,6 +1,8 @@ --- en: UI: + period_picker: + aria_label: "Time period: %{period}" account: activity_feed: toggle_selection_checkboxes: Toggle selection diff --git a/config/locales/views/enable_banking_items/ca.yml b/config/locales/views/enable_banking_items/ca.yml index 432240782..f05a0386f 100644 --- a/config/locales/views/enable_banking_items/ca.yml +++ b/config/locales/views/enable_banking_items/ca.yml @@ -4,8 +4,6 @@ ca: authorize: authorization_failed: 'No s''ha pogut iniciar l''autorització: %{message}' bank_required: Selecciona un banc. - decoupled_not_supported: Aquest banc utilitza un mètode d'autenticació amb dispositiu - separat que encara no està admès. Afegeix aquest compte manualment. invalid_redirect: L'URL d'autorització rebuda no és vàlida. Torna-ho a provar. redirect_uri_not_allowed: Redirecció no permesa. Configura `%{callback_url}` a la configuració de la teva aplicació Enable Banking. diff --git a/config/locales/views/enable_banking_items/en.yml b/config/locales/views/enable_banking_items/en.yml index ab6061433..b3eb1f15f 100644 --- a/config/locales/views/enable_banking_items/en.yml +++ b/config/locales/views/enable_banking_items/en.yml @@ -9,7 +9,6 @@ en: authorize: authorization_failed: "Failed to initiate authorization: %{message}" bank_required: Please select a bank. - decoupled_not_supported: This bank uses a separate device authentication method which is not yet supported. Please add this account manually. invalid_redirect: The authorization URL received is invalid. Please try again. redirect_uri_not_allowed: Redirect not allowed. Please configure `%{callback_url}` in your Enable Banking app settings. unexpected_error: An unexpected error occurred. Please try again. diff --git a/config/locales/views/enable_banking_items/fr.yml b/config/locales/views/enable_banking_items/fr.yml index 1c6684aac..2b012a169 100644 --- a/config/locales/views/enable_banking_items/fr.yml +++ b/config/locales/views/enable_banking_items/fr.yml @@ -9,7 +9,6 @@ fr: authorize: authorization_failed: Échec de l'initiation de l'autorisation bank_required: Veuillez sélectionner une banque. - decoupled_not_supported: Cette banque utilise une méthode d'authentification sur un appareil séparé qui n'est pas encore prise en charge. Veuillez ajouter ce compte manuellement. invalid_redirect: L'URL d'autorisation reçue est invalide. Veuillez réessayer. redirect_uri_not_allowed: Redirection non autorisée. Veuillez configurer `%{callback_url}` dans les paramètres de votre application Enable Banking. unexpected_error: Une erreur inattendue s'est produite. Veuillez réessayer. diff --git a/config/locales/views/enable_banking_items/hu.yml b/config/locales/views/enable_banking_items/hu.yml index 79a3282f0..634c44f3c 100644 --- a/config/locales/views/enable_banking_items/hu.yml +++ b/config/locales/views/enable_banking_items/hu.yml @@ -9,7 +9,6 @@ hu: authorize: authorization_failed: "Nem sikerült elindítani az engedélyezést: %{message}" bank_required: Kérjük, válassz bankot. - decoupled_not_supported: Ez a bank egy különálló eszközös hitelesítési módszert használ, amely jelenleg nem támogatott. Kérjük, add hozzá ezt a számlát manuálisan. invalid_redirect: A kapott engedélyezési URL érvénytelen. Kérjük, próbáld újra. redirect_uri_not_allowed: Az átirányítás nem engedélyezett. Kérjük, konfiguráld a `%{callback_url}` címet az Enable Banking alkalmazás beállításaiban. unexpected_error: Váratlan hiba történt. Kérjük, próbáld újra. diff --git a/config/locales/views/enable_banking_items/vi.yml b/config/locales/views/enable_banking_items/vi.yml index 138ba872d..8d395d14f 100644 --- a/config/locales/views/enable_banking_items/vi.yml +++ b/config/locales/views/enable_banking_items/vi.yml @@ -9,7 +9,6 @@ vi: authorize: authorization_failed: "Không thể khởi tạo ủy quyền: %{message}" bank_required: Vui lòng chọn ngân hàng. - decoupled_not_supported: Ngân hàng này sử dụng phương thức xác thực thiết bị riêng biệt chưa được hỗ trợ. Vui lòng thêm tài khoản này thủ công. invalid_redirect: URL ủy quyền nhận được không hợp lệ. Vui lòng thử lại. redirect_uri_not_allowed: Chuyển hướng không được phép. Vui lòng cấu hình `%{callback_url}` trong cài đặt ứng dụng Enable Banking của bạn. unexpected_error: Đã xảy ra lỗi không mong muốn. Vui lòng thử lại. diff --git a/config/locales/views/enable_banking_items/zh-CN.yml b/config/locales/views/enable_banking_items/zh-CN.yml index 3bf277767..7038de650 100644 --- a/config/locales/views/enable_banking_items/zh-CN.yml +++ b/config/locales/views/enable_banking_items/zh-CN.yml @@ -9,7 +9,6 @@ zh-CN: authorize: authorization_failed: 启动授权失败:%{message} bank_required: 请选择一家银行。 - decoupled_not_supported: 该银行使用独立设备认证方式,目前尚不支持。请手动添加此账户。 invalid_redirect: 收到的授权 URL 无效。请重试。 redirect_uri_not_allowed: 不允许重定向。请在 Enable Banking 应用设置中配置 `%{callback_url}`。 unexpected_error: 发生意外错误。请重试。 diff --git a/config/locales/views/ibkr_items/de.yml b/config/locales/views/ibkr_items/de.yml new file mode 100644 index 000000000..0190a74bc --- /dev/null +++ b/config/locales/views/ibkr_items/de.yml @@ -0,0 +1,92 @@ +--- +de: + providers: + ibkr: + name: Interactive Brokers + connection_description: Verbinden Sie einen Interactive Brokers Flex Web Service-Bericht + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Löschung läuft + flex_web_service: Flex Web Service + syncing: Synchronisiere + requires_update: Zugangsdaten erfordern Aufmerksamkeit + error: Fehler + synced: Vor %{time} synchronisiert. %{summary}. + never_synced: Noch nie synchronisiert. + setup_accounts: Konten einrichten + delete: Löschen + accounts_need_setup: Konten müssen eingerichtet werden + accounts_need_setup_description: Einige Konten von IBKR müssen mit Sure-Konten verknüpft werden. + no_accounts_discovered: Noch keine IBKR-Konten gefunden. + no_accounts_discovered_description: Führen Sie nach der Konfiguration Ihrer Flex-Abfrage eine Synchronisierung durch, um Konten zu finden. + setup_accounts: + page_title: Interactive Brokers-Konten einrichten + dialog_title: Richten Sie Ihre Interactive Brokers-Konten ein + subtitle: Wählen Sie aus, welche IBKR-Brokerage-Konten verknüpft werden sollen. + info_box: + title: IBKR Flex-Abfrage-Import + items: + item_1: Bestände mit aktuellen Kursen und Stückzahlen + item_2: Einstandskosten pro Position + item_3: Trades, Dividenden, Provisionen sowie Bareinzahlungen und -abhebungen + warning: Historische Aktivitäten sind auf den Berichtszeitraum der Flex-Abfrage beschränkt + status: + fetching_accounts: Konten werden von Interactive Brokers abgerufen... + no_accounts_found_title: Keine Konten gefunden. + no_accounts_found_description: Sure konnte im neuesten Flex-Bericht keine IBKR-Konten finden. + available_accounts: + title: Verfügbare Konten + account_type_investment: Investment + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "Konto-ID: %{account_id}" + link_existing: + description: Oder verknüpfen Sie ein gefundenes IBKR-Konto mit einem bestehenden manuellen Investmentkonto. + manual_account_option: "%{name} (%{balance})" + select_prompt: Konto auswählen... + linked_accounts: + title: Bereits verknüpft + linked_to_html: "Verknüpft mit: %{account}" + buttons: + refresh: Aktualisieren + cancel: Abbrechen + back_to_settings: Zurück zu den Einstellungen + create_selected_accounts: Ausgewählte Konten erstellen + link: Verknüpfen + done: Fertig + sync_status: + no_accounts: Noch keine IBKR-Konten gefunden + all_linked: + one: 1 Konto verknüpft + other: "%{count} Konten verknüpft" + partial: "%{linked} verknüpft, %{unlinked} müssen eingerichtet werden" + select_existing_account: + title: Interactive Brokers-Konto verknüpfen + no_accounts_available: Es sind noch keine nicht verknüpften Interactive Brokers-Konten verfügbar. + run_sync_hint: "Führen Sie unter Einstellungen > Anbieter eine Synchronisierung durch, nachdem Sie Ihre Flex-Abfrage aktualisiert haben." + wait_for_sync: Warten Sie, bis die Synchronisierung zur Kontoerkennung abgeschlossen ist. + balance: Saldo + link: Verknüpfen + cancel: Abbrechen + create: + success: Interactive Brokers erfolgreich konfiguriert. + update: + success: Interactive Brokers-Konfiguration erfolgreich aktualisiert. + destroy: + success: Interactive Brokers-Verbindung zur Löschung vorgemerkt. + select_accounts: + not_configured: Interactive Brokers ist nicht konfiguriert. + link_existing_account: + not_found: Konto oder Interactive Brokers-Konfiguration nicht gefunden. + only_manual_investment: Nur manuelle Investmentkonten können mit Interactive Brokers verknüpft werden. + already_linked: Dieses Interactive Brokers-Konto ist bereits verknüpft. + success: Erfolgreich mit Interactive Brokers-Konto verknüpft. + failed: Verknüpfung mit Interactive Brokers-Konto fehlgeschlagen. + complete_account_setup: + success: + one: "%{count} Interactive Brokers-Konto erfolgreich erstellt." + other: "%{count} Interactive Brokers-Konten erfolgreich erstellt." + none_selected: Es wurden keine Konten ausgewählt. + none_created: Es wurden keine Konten erstellt. diff --git a/config/locales/views/ibkr_items/es.yml b/config/locales/views/ibkr_items/es.yml new file mode 100644 index 000000000..857d308fb --- /dev/null +++ b/config/locales/views/ibkr_items/es.yml @@ -0,0 +1,92 @@ +--- +es: + providers: + ibkr: + name: Interactive Brokers + connection_description: Conecta un informe del Flex Web Service de Interactive Brokers + institution_name: Interactive Brokers + ibkr_items: + defaults: + name: Interactive Brokers + ibkr_item: + deletion_in_progress: Eliminación en curso + flex_web_service: Flex Web Service + syncing: Sincronizando + requires_update: Las credenciales requieren atención + error: Error + synced: Sincronizado hace %{time}. %{summary}. + never_synced: Nunca sincronizado. + setup_accounts: Configurar cuentas + delete: Eliminar + accounts_need_setup: Las cuentas necesitan configuración + accounts_need_setup_description: Algunas cuentas de IBKR deben vincularse a cuentas de Sure. + no_accounts_discovered: Aún no se han detectado cuentas de IBKR. + no_accounts_discovered_description: Ejecuta una sincronización después de configurar tu consulta Flex para detectar cuentas. + setup_accounts: + page_title: Configurar cuentas de Interactive Brokers + dialog_title: Configura tus cuentas de Interactive Brokers + subtitle: Selecciona qué cuentas de bróker de IBKR quieres vincular. + info_box: + title: Importación mediante consulta Flex de IBKR + items: + item_1: Posiciones con precios y cantidades actuales + item_2: Coste base por posición + item_3: Operaciones, dividendos, comisiones y depósitos o retiradas de efectivo + warning: La actividad histórica se limita al periodo del informe de la consulta Flex + status: + fetching_accounts: Obteniendo cuentas de Interactive Brokers... + no_accounts_found_title: No se han encontrado cuentas. + no_accounts_found_description: Sure no pudo encontrar ninguna cuenta de IBKR en el último informe Flex. + available_accounts: + title: Cuentas disponibles + account_type_investment: Inversión + account_summary: "%{account_type} • Saldo: %{balance}" + account_id: "ID de cuenta: %{account_id}" + link_existing: + description: O vincula una cuenta de IBKR detectada a una cuenta de inversión manual existente. + manual_account_option: "%{name} (%{balance})" + select_prompt: Selecciona una cuenta... + linked_accounts: + title: Ya vinculadas + linked_to_html: "Vinculada a: %{account}" + buttons: + refresh: Actualizar + cancel: Cancelar + back_to_settings: Volver a Ajustes + create_selected_accounts: Crear cuentas seleccionadas + link: Vincular + done: Hecho + sync_status: + no_accounts: Aún no se han detectado cuentas de IBKR + all_linked: + one: 1 cuenta vinculada + other: "%{count} cuentas vinculadas" + partial: "%{linked} vinculadas, %{unlinked} necesitan configuración" + select_existing_account: + title: Vincular cuenta de Interactive Brokers + no_accounts_available: Aún no hay cuentas de Interactive Brokers sin vincular disponibles. + run_sync_hint: "Ejecuta una sincronización desde Ajustes > Proveedores después de actualizar tu consulta Flex." + wait_for_sync: Espera a que termine la sincronización de detección de cuentas. + balance: Saldo + link: Vincular + cancel: Cancelar + create: + success: Interactive Brokers configurado correctamente. + update: + success: Configuración de Interactive Brokers actualizada correctamente. + destroy: + success: Conexión de Interactive Brokers programada para su eliminación. + select_accounts: + not_configured: Interactive Brokers no está configurado. + link_existing_account: + not_found: No se ha encontrado la cuenta o la configuración de Interactive Brokers. + only_manual_investment: Solo las cuentas de inversión manuales pueden vincularse a Interactive Brokers. + already_linked: Esta cuenta de Interactive Brokers ya está vinculada. + success: Vinculado correctamente a la cuenta de Interactive Brokers. + failed: No se pudo vincular la cuenta de Interactive Brokers. + complete_account_setup: + success: + one: Se ha creado %{count} cuenta de Interactive Brokers correctamente. + other: Se han creado %{count} cuentas de Interactive Brokers correctamente. + none_selected: No se ha seleccionado ninguna cuenta. + none_created: No se ha creado ninguna cuenta. diff --git a/config/locales/views/kraken_items/de.yml b/config/locales/views/kraken_items/de.yml new file mode 100644 index 000000000..2a31167b0 --- /dev/null +++ b/config/locales/views/kraken_items/de.yml @@ -0,0 +1,85 @@ +--- +de: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Mit einem Kraken-Börsenkonto verknüpfen + name: "Kraken - %{name}" + description: "Mit %{name} verknüpfen" + create: + default_name: Kraken + success: Erfolgreich mit Kraken verbunden. Ihr Börsenkonto wird synchronisiert. + update: + success: Kraken-Verbindung erfolgreich aktualisiert. + destroy: + success: Kraken-Verbindung zur Löschung vorgemerkt. + select_accounts: + select_connection: Wählen Sie in den Anbietereinstellungen eine Kraken-Verbindung aus. + no_credentials_configured: Fügen Sie Kraken-API-Zugangsdaten hinzu, bevor Sie Konten einrichten. + link_accounts: + select_connection: Wählen Sie eine Kraken-Verbindung aus, bevor Sie Konten verknüpfen. + select_existing_account: + title: Kraken-Konto verknüpfen + no_accounts_found: Keine Kraken-Konten gefunden. + wait_for_sync: Warten Sie, bis Kraken die Synchronisierung abgeschlossen hat. + check_provider_health: Prüfen Sie, ob Ihre Kraken-API-Zugangsdaten gültig sind. + link: Verknüpfen + cancel: Abbrechen + link_existing_account: + success: Erfolgreich mit Kraken-Konto verknüpft + select_connection: Wählen Sie eine Kraken-Verbindung aus, bevor Sie Konten verknüpfen. + errors: + only_manual: Nur manuelle Krypto-Börsenkonten ohne bestehende Anbieterverknüpfung können mit Kraken verknüpft werden + invalid_kraken_account: Ungültiges Kraken-Konto + kraken_account_already_linked: Dieses Kraken-Konto ist bereits verknüpft + setup_accounts: + title: Kraken-Konto importieren + subtitle: Wählen Sie das zu verfolgende Börsenkonto + instructions: Kraken importiert für diese Verbindung ein kombiniertes Krypto-Börsenkonto, ausschließlich mit Beständen und Spot-Trade-Ausführungen. + no_accounts: Alle Kraken-Konten wurden importiert. + accounts_count: + one: "%{count} Konto verfügbar" + other: "%{count} Konten verfügbar" + select_all: Alle auswählen + import_selected: Ausgewählte importieren + cancel: Abbrechen + creating: Importiere... + complete_account_setup: + success: + one: "%{count} Konto importiert" + other: "%{count} Konten importiert" + none_selected: Keine Konten ausgewählt + no_accounts: Keine Konten zum Import + kraken_item: + provider_name: Kraken + syncing: Synchronisiere... + reconnect: Zugangsdaten müssen aktualisiert werden + deletion_in_progress: Wird gelöscht... + sync_status: + no_accounts: Keine Konten gefunden + all_synced: + one: "%{count} Konto synchronisiert" + other: "%{count} Konten synchronisiert" + partial_sync: "%{linked_count} synchronisiert, %{unlinked_count} müssen eingerichtet werden" + status: "Zuletzt synchronisiert vor %{timestamp}" + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} – %{summary}" + status_never: Noch nie synchronisiert + delete: Löschen + no_accounts_title: Keine Konten gefunden + no_accounts_message: Ihr Kraken-Börsenkonto erscheint hier nach der Synchronisierung. + setup_needed: Konto bereit zum Import + setup_description: Importieren Sie diese Kraken-Verbindung als Krypto-Börsenkonto. + setup_action: Konto importieren + import_accounts_menu: Konto importieren + stale_rate_warning: "Der Saldo ist ein Näherungswert, da der genaue Wechselkurs für %{date} nicht verfügbar war. Er wird bei der nächsten Synchronisierung aktualisiert." + kraken_item: + syncer: + checking_credentials: Zugangsdaten werden geprüft... + credentials_invalid: Ungültige Kraken-API-Zugangsdaten. Bitte überprüfen Sie Ihren API-Schlüssel und Ihr Secret. + importing_accounts: Konten werden von Kraken importiert... + checking_configuration: Kontokonfiguration wird geprüft... + accounts_need_setup: + one: "%{count} Konto muss eingerichtet werden" + other: "%{count} Konten müssen eingerichtet werden" + processing_accounts: Kontodaten werden verarbeitet... + calculating_balances: Salden werden berechnet... diff --git a/config/locales/views/kraken_items/es.yml b/config/locales/views/kraken_items/es.yml new file mode 100644 index 000000000..26a3eaaf8 --- /dev/null +++ b/config/locales/views/kraken_items/es.yml @@ -0,0 +1,85 @@ +--- +es: + kraken_items: + provider_connection: + default_name: Kraken + default_description: Vincular a una cuenta de la plataforma de intercambio Kraken + name: "Kraken - %{name}" + description: "Vincular a %{name}" + create: + default_name: Kraken + success: Conexión con Kraken establecida con éxito. Tu cuenta de la plataforma se está sincronizando. + update: + success: Conexión de Kraken actualizada correctamente. + destroy: + success: Conexión de Kraken programada para su eliminación. + select_accounts: + select_connection: Elige una conexión de Kraken en los Ajustes del proveedor. + no_credentials_configured: Añade las credenciales de la API de Kraken antes de configurar las cuentas. + link_accounts: + select_connection: Elige una conexión de Kraken antes de vincular cuentas. + select_existing_account: + title: Vincular cuenta de Kraken + no_accounts_found: No se han encontrado cuentas de Kraken. + wait_for_sync: Espera a que Kraken termine de sincronizar. + check_provider_health: Comprueba que tus credenciales de la API de Kraken sean válidas. + link: Vincular + cancel: Cancelar + link_existing_account: + success: Vinculado correctamente a la cuenta de Kraken + select_connection: Elige una conexión de Kraken antes de vincular cuentas. + errors: + only_manual: Solo las cuentas manuales de plataforma de criptomonedas sin un vínculo de proveedor existente pueden vincularse a Kraken + invalid_kraken_account: Cuenta de Kraken no válida + kraken_account_already_linked: Esta cuenta de Kraken ya está vinculada + setup_accounts: + title: Importar cuenta de Kraken + subtitle: Selecciona la cuenta de la plataforma que quieres seguir + instructions: Kraken importa una única cuenta combinada de plataforma de criptomonedas para esta conexión, con solo posiciones y ejecuciones de operaciones al contado. + no_accounts: Se han importado todas las cuentas de Kraken. + accounts_count: + one: "%{count} cuenta disponible" + other: "%{count} cuentas disponibles" + select_all: Seleccionar todas + import_selected: Importar seleccionadas + cancel: Cancelar + creating: Importando... + complete_account_setup: + success: + one: "Se ha importado %{count} cuenta" + other: "Se han importado %{count} cuentas" + none_selected: No se ha seleccionado ninguna cuenta + no_accounts: No hay cuentas para importar + kraken_item: + provider_name: Kraken + syncing: Sincronizando... + reconnect: Es necesario actualizar las credenciales + deletion_in_progress: Eliminando... + sync_status: + no_accounts: No se han encontrado cuentas + all_synced: + one: "%{count} cuenta sincronizada" + other: "%{count} cuentas sincronizadas" + partial_sync: "%{linked_count} sincronizadas, %{unlinked_count} necesitan configuración" + status: "Sincronizado hace %{timestamp}" + status_with_summary: "Sincronizado hace %{timestamp} - %{summary}" + status_never: Nunca sincronizado + delete: Eliminar + no_accounts_title: No se han encontrado cuentas + no_accounts_message: Tu cuenta de la plataforma Kraken aparecerá aquí después de la sincronización. + setup_needed: Cuenta lista para importar + setup_description: Importa esta conexión de Kraken como una cuenta de plataforma de criptomonedas. + setup_action: Importar cuenta + import_accounts_menu: Importar cuenta + stale_rate_warning: "El saldo es aproximado porque el tipo de cambio exacto para %{date} no estaba disponible. Se actualizará en la próxima sincronización." + kraken_item: + syncer: + checking_credentials: Comprobando credenciales... + credentials_invalid: Credenciales de la API de Kraken no válidas. Comprueba tu clave de API y tu secreto. + importing_accounts: Importando cuentas desde Kraken... + checking_configuration: Comprobando la configuración de la cuenta... + accounts_need_setup: + one: "%{count} cuenta necesita configuración" + other: "%{count} cuentas necesitan configuración" + processing_accounts: Procesando los datos de la cuenta... + calculating_balances: Calculando saldos... diff --git a/config/locales/views/messages/de.yml b/config/locales/views/messages/de.yml new file mode 100644 index 000000000..17e199c34 --- /dev/null +++ b/config/locales/views/messages/de.yml @@ -0,0 +1,6 @@ +--- +de: + messages: + chat_form: + placeholder: "Fragen Sie irgendetwas ..." + disclaimer: "KI-Antworten dienen nur zur Information. Keine Finanzberatung!" diff --git a/config/locales/views/messages/es.yml b/config/locales/views/messages/es.yml new file mode 100644 index 000000000..b5d1a4924 --- /dev/null +++ b/config/locales/views/messages/es.yml @@ -0,0 +1,6 @@ +--- +es: + messages: + chat_form: + placeholder: "Pregunta lo que quieras ..." + disclaimer: "Las respuestas de la IA son solo informativas. ¡No constituyen asesoramiento financiero!" diff --git a/config/locales/views/pending_duplicate_merges/de.yml b/config/locales/views/pending_duplicate_merges/de.yml new file mode 100644 index 000000000..6a7a5ef60 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/de.yml @@ -0,0 +1,21 @@ +--- +de: + pending_duplicate_merges: + create: + no_posted_selected: Bitte wählen Sie eine gebuchte Transaktion zum Zusammenführen aus + invalid_transaction: Ungültige Transaktion zum Zusammenführen ausgewählt + merge_success: Ausstehende Transaktion mit gebuchter Transaktion zusammengeführt + merge_failed: Transaktionen konnten nicht zusammengeführt werden + set_transaction: + pending_only: Diese Funktion ist nur für ausstehende Transaktionen verfügbar + new: + title: Mit gebuchter Transaktion zusammenführen + warning_title: Manuelles Zusammenführen von Duplikaten + warning_description: Verwenden Sie dies, um eine ausstehende Transaktion manuell mit ihrer gebuchten Version zusammenzuführen. Dadurch wird die ausstehende Transaktion gelöscht und nur die gebuchte beibehalten. + pending_transaction: Ausstehende Transaktion + select_posted: Gebuchte Transaktion zum Zusammenführen auswählen + showing_range: "%{start} - %{end} werden angezeigt" + previous: "← Vorherige 10" + next: "Nächste 10 →" + no_candidates: Keine gebuchten Transaktionen in diesem Konto gefunden. + submit_button: Transaktionen zusammenführen diff --git a/config/locales/views/pending_duplicate_merges/es.yml b/config/locales/views/pending_duplicate_merges/es.yml new file mode 100644 index 000000000..d85917824 --- /dev/null +++ b/config/locales/views/pending_duplicate_merges/es.yml @@ -0,0 +1,21 @@ +--- +es: + pending_duplicate_merges: + create: + no_posted_selected: Selecciona una transacción contabilizada con la que fusionar + invalid_transaction: Transacción seleccionada para fusionar no válida + merge_success: Transacción pendiente fusionada con la transacción contabilizada + merge_failed: No se pudieron fusionar las transacciones + set_transaction: + pending_only: Esta función solo está disponible para transacciones pendientes + new: + title: Fusionar con transacción contabilizada + warning_title: Fusión manual de duplicados + warning_description: Usa esto para fusionar manualmente una transacción pendiente con su versión contabilizada. Se eliminará la transacción pendiente y se conservará únicamente la contabilizada. + pending_transaction: Transacción pendiente + select_posted: Selecciona la transacción contabilizada con la que fusionar + showing_range: "Mostrando %{start} - %{end}" + previous: "← 10 anteriores" + next: "10 siguientes →" + no_candidates: No se han encontrado transacciones contabilizadas en esta cuenta. + submit_button: Fusionar transacciones diff --git a/config/locales/views/securities/de.yml b/config/locales/views/securities/de.yml new file mode 100644 index 000000000..7c914d341 --- /dev/null +++ b/config/locales/views/securities/de.yml @@ -0,0 +1,14 @@ +--- +de: + securities: + combobox: + display: "%{symbol} - %{name} (%{exchange})" + exchange_label: "%{symbol} (%{exchange})" + providers: + twelve_data: Twelve Data + yahoo_finance: Yahoo Finance + tiingo: Tiingo + eodhd: EODHD + alpha_vantage: Alpha Vantage + mfapi: MFAPI.in + binance_public: Binance diff --git a/config/locales/views/sophtron_items/de.yml b/config/locales/views/sophtron_items/de.yml new file mode 100644 index 000000000..7701dfd25 --- /dev/null +++ b/config/locales/views/sophtron_items/de.yml @@ -0,0 +1,313 @@ +--- +de: + sophtron_items: + defaults: + name: Sophtron-Verbindung + new: + title: Sophtron verbinden + user_id: Benutzer-ID + user_id_placeholder: Fügen Sie Ihre Sophtron-Benutzer-ID ein + access_key: Zugriffsschlüssel + access_key_placeholder: Fügen Sie Ihren Sophtron-Zugriffsschlüssel ein + connect: Verbinden + cancel: Abbrechen + create: + success: Sophtron-Verbindung erfolgreich erstellt + destroy: + success: Sophtron-Verbindung entfernt + update: + success: Sophtron-Verbindung erfolgreich aktualisiert! Ihre Konten werden erneut verbunden. + errors: + blank_user_id: Bitte geben Sie eine Sophtron-Benutzer-ID ein. + invalid_user_id: Ungültige Benutzer-ID. Bitte prüfen Sie, ob Sie die vollständige Benutzer-ID von Sophtron kopiert haben. + user_id_compromised: Die Benutzer-ID ist möglicherweise kompromittiert, abgelaufen oder bereits verwendet. Bitte erstellen Sie eine neue. + blank_access_key: Bitte geben Sie einen Sophtron-Zugriffsschlüssel ein. + invalid_access_key: Ungültiger Zugriffsschlüssel. Bitte prüfen Sie, ob Sie den vollständigen Zugriffsschlüssel von Sophtron kopiert haben. + access_key_compromised: Der Zugriffsschlüssel ist möglicherweise kompromittiert, abgelaufen oder bereits verwendet. Bitte erstellen Sie einen neuen. + update_failed: "Verbindung konnte nicht aktualisiert werden: %{message}" + unexpected: Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an den Support. + edit: + user_id: + label: "Sophtron-Benutzer-ID:" + placeholder: "Fügen Sie hier Ihre Sophtron-Benutzer-ID ein..." + help_text: "Die Benutzer-ID sollte eine lange Zeichenfolge sein, die mit Buchstaben und Zahlen beginnt" + access_key: + label: "Sophtron-Zugriffsschlüssel:" + placeholder: "Fügen Sie hier Ihren Sophtron-Zugriffsschlüssel ein..." + help_text: "Der Zugriffsschlüssel sollte eine lange Zeichenfolge sein, die mit Buchstaben und Zahlen beginnt" + index: + title: Sophtron-Verbindungen + loading: + loading_message: Sophtron-Konten werden geladen... + loading_title: Wird geladen + link_accounts: + all_already_linked: + one: "Das ausgewählte Konto (%{names}) ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft: %{names}" + api_error: "API-Verbindungsfehler" + invalid_account_names: + one: "Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + link_failed: Konten konnten nicht verknüpft werden + no_accounts_selected: Bitte wählen Sie mindestens ein Konto aus + partial_invalid: "%{created_count} Konto(en) erfolgreich verknüpft, %{already_linked_count} waren bereits verknüpft, %{invalid_count} Konto(en) hatten ungültige Namen" + partial_success: "%{created_count} Konto(en) erfolgreich verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft: %{already_linked_names}" + success: + one: "%{count} Konto erfolgreich verknüpft" + other: "%{count} Konten erfolgreich verknüpft" + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + connect: + cancel: Abbrechen + captcha: Captcha + connect: Verbinden + institution_search_label: Institut + institution_search_placeholder: Nach Bankname suchen + no_institutions: Keine passenden Institute gefunden. + password: Passwort + search: Suchen + search_too_short: Geben Sie mindestens zwei Zeichen für die Suche ein. + title: Sophtron-Institut verbinden + username: Benutzername + connect_institution: + api_error: "Sophtron-Verbindung fehlgeschlagen: %{message}" + missing_parameters: Wählen Sie ein Institut aus und geben Sie Ihre Bank-Anmeldedaten ein. + connection_status: + api_error: "API-Verbindungsfehler: %{message}" + attempt: "Versuch %{attempt} von %{max}" + check_again: Erneut prüfen + failed: Sophtron konnte diese Institutsverbindung nicht abschließen. + failed_timeout: Bei Sophtron ist eine Zeitüberschreitung aufgetreten, während das Institut die Anmeldung abschloss. + timeout: Sophtron hat die Verbindung nicht innerhalb der erwarteten Zeit abgeschlossen. Sie können erneut prüfen oder später erneut versuchen. + title: Sophtron wird verbunden + waiting: Sophtron verbindet sich noch mit Ihrem Institut. + mfa: + captcha: Captcha-Text + captcha_alt: Sophtron-Captcha + phone_confirmed: Ich habe telefonisch bestätigt + submit: Absenden + title: Sophtron-Verifizierung + token: Verifizierungscode + submit_mfa: + api_error: "Verifizierung fehlgeschlagen: %{message}" + invalid_security_answers: Sicherheitsantworten fehlen oder sind zu lang. + unknown_challenge: Unbekannter Sophtron-Verifizierungsschritt. + sophtron_item: + accounts_need_setup: Konten müssen eingerichtet werden + automatic_sync: Automatische Synchronisierung verwenden + delete: Verbindung löschen + deletion_in_progress: Löschung läuft... + error: Fehler + no_accounts_description: Diese Verbindung hat noch keine verknüpften Konten. + no_accounts_title: Keine Konten + manual_sync: Manuelle Synchronisierung + manual_sync_action: Manuelle Synchronisierung erforderlich machen + manual_sync_action_for: "Manuelle Synchronisierung für %{institution} erforderlich machen" + automatic_sync_for: "Automatische Synchronisierung für %{institution} verwenden" + setup_action: Neue Konten einrichten + setup_description: "%{linked} von %{total} Konten verknüpft. Wählen Sie Kontotypen für Ihre neu importierten Sophtron-Konten." + setup_needed: Neue Konten bereit zur Einrichtung + status: "Vor %{timestamp} synchronisiert" + status_never: Noch nie synchronisiert + status_with_summary: "Zuletzt synchronisiert vor %{timestamp} • %{summary}" + sync_now: Jetzt synchronisieren + syncing: Synchronisiere... + total: Gesamt + unlinked: Nicht verknüpft + preload_accounts: + preload_accounts: Konten vorab laden + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + select_accounts: + accounts_selected: Konten ausgewählt + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + cancel: Abbrechen + configure_name_in_sophtron: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Sophtron + description: Wählen Sie die Konten aus, die Sie mit Ihrem %{product_name}-Konto verknüpfen möchten. + link_accounts: Ausgewählte Konten verknüpfen + no_accounts_found: Keine Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_credentials_configured: "Bitte konfigurieren Sie zuerst Ihre Sophtron-API-Benutzer-ID und Ihren Zugriffsschlüssel in den Anbietereinstellungen." + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + no_name_placeholder: "(Kein Name)" + title: Sophtron-Konten auswählen + select_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + all_accounts_already_linked: Alle Sophtron-Konten sind bereits verknüpft + api_error: "API-Verbindungsfehler" + cancel: Abbrechen + configure_name_in_sophtron: Import nicht möglich – bitte konfigurieren Sie den Kontonamen in Sophtron + description: Wählen Sie ein Sophtron-Konto aus, das mit diesem Konto verknüpft werden soll. Transaktionen werden automatisch synchronisiert und dedupliziert. + link_account: Konto verknüpfen + no_account_specified: Kein Konto angegeben + no_accounts_found: Keine Sophtron-Konten gefunden. Bitte überprüfen Sie Ihre API-Schlüssel-Konfiguration. + no_access_key: Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte konfigurieren Sie ihn in den Einstellungen. + no_user_id: Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte konfigurieren Sie sie in den Einstellungen. + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + no_name_placeholder: "(Kein Name)" + title: "%{account_name} mit Sophtron verknüpfen" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + link_existing_account: + account_already_linked: Dieses Konto ist bereits mit einem Anbieter verknüpft + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + invalid_account_name: Konto mit leerem Namen kann nicht verknüpft werden + sophtron_account_already_linked: Dieses Sophtron-Konto ist bereits mit einem anderen Konto verknüpft + sophtron_account_not_found: Sophtron-Konto nicht gefunden + missing_parameters: Erforderliche Parameter fehlen + no_institution_connected: Bitte verbinden Sie zuerst ein Bankinstitut mit Sophtron. + success: "%{account_name} erfolgreich mit Sophtron verknüpft" + setup_accounts: + account_type_label: "Kontotyp:" + all_accounts_linked: "Alle Ihre Sophtron-Konten wurden bereits eingerichtet." + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + fetch_failed: "Konten konnten nicht abgerufen werden" + no_accounts_to_setup: "Keine Konten zum Einrichten" + no_access_key: "Sophtron-Zugriffsschlüssel ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + no_user_id: "Sophtron-Benutzer-ID ist nicht konfiguriert. Bitte überprüfen Sie Ihre Verbindungseinstellungen." + no_institution_connected: "Sophtron-Institut ist noch nicht verbunden." + account_types: + skip: Dieses Konto überspringen + depository: Giro- oder Sparkonto + credit_card: Kreditkarte + investment: Investmentkonto + loan: Darlehen oder Hypothek + other_asset: Sonstiger Vermögenswert + subtype_labels: + depository: "Kontountertyp:" + credit_card: "" + investment: "Investmenttyp:" + loan: "Darlehenstyp:" + other_asset: "" + subtype_messages: + credit_card: "Kreditkarten werden automatisch als Kreditkartenkonten eingerichtet." + other_asset: "Für sonstige Vermögenswerte sind keine zusätzlichen Optionen erforderlich." + balance: Saldo + cancel: Abbrechen + choose_account_type: "Wählen Sie den richtigen Kontotyp für jedes Sophtron-Konto:" + create_accounts: Konten erstellen + creating_accounts: Konten werden erstellt... + historical_data_range: "Historischer Datenbereich:" + subtitle: Wählen Sie die richtigen Kontotypen für Ihre importierten Konten + sync_start_date_help: Wählen Sie aus, wie weit zurück Sie den Transaktionsverlauf synchronisieren möchten. Maximal 3 Jahre Verlauf verfügbar. + sync_start_date_label: "Transaktionen synchronisieren ab:" + title: Richten Sie Ihre Sophtron-Konten ein + complete_account_setup: + all_skipped: "Alle Konten wurden übersprungen. Es wurden keine Konten erstellt." + creation_failed: "Konten konnten nicht erstellt werden" + api_error: "API-Verbindungsfehler" + unexpected_error: "Ein unerwarteter Fehler ist aufgetreten" + no_accounts: "Keine Konten zum Einrichten." + success: "%{count} Konto(en) erfolgreich erstellt." + sync: + already_running: Eine manuelle Sophtron-Synchronisierung läuft bereits. + api_error: "Manuelle Sophtron-Synchronisierung fehlgeschlagen: %{message}" + failed: Manuelle Sophtron-Synchronisierung fehlgeschlagen + no_linked_accounts: Dieses Sophtron-Institut hat keine verknüpften Konten zum Synchronisieren. + processing_failed: Die manuelle Sophtron-Synchronisierung konnte die aktualisierten Transaktionen nicht verarbeiten. + success: Synchronisierung gestartet + toggle_manual_sync: + success_disabled: Sophtron-Institut wird automatisch synchronisiert. + success_enabled: Sophtron-Institut erfordert nun eine manuelle Synchronisierung. + manual_sync_complete: + close: Schließen + description: Die Kontosalden werden im Hintergrund fertig aktualisiert. + message: Die Transaktionen wurden nach der Sophtron-Verifizierung heruntergeladen. + title: Sophtron-Synchronisierung gestartet + sophtron_setup_required: + title: Sophtron-Einrichtung erforderlich + message: > + Um die Einrichtung Ihrer Sophtron-Verbindung abzuschließen, gehen Sie bitte zur Seite mit den Anbietereinstellungen und folgen Sie den Anweisungen, um Ihre Sophtron-Verbindung zu autorisieren und zu konfigurieren. + go_to_provider_settings: Zu den Anbietereinstellungen + heading: "Benutzer-ID und Zugriffsschlüssel nicht konfiguriert" + description: "Bevor Sie Sophtron-Konten verknüpfen können, müssen Sie Ihre Sophtron-Benutzer-ID und Ihren Zugriffsschlüssel konfigurieren." + setup_steps_title: "Einrichtungsschritte:" + step_1_html: "Gehen Sie zu Einstellungen → Bank-Sync-Anbieter" + step_2_html: "Suchen Sie den Bereich Sophtron" + step_3_html: "Geben Sie Ihre Sophtron-Benutzer-ID und Ihren Zugriffsschlüssel ein" + step_4: "Kehren Sie hierher zurück, um Ihre Konten zu verknüpfen" + api_error: + title: "Sophtron-Verbindungsfehler" + unable_to_connect: "Verbindung zu Sophtron nicht möglich" + institution_unable_to_connect: "Verbindung zum Institut nicht möglich" + common_issues_title: "Häufige Probleme:" + incorrect_user_id: "Falsche Benutzer-ID: Überprüfen Sie Ihre Benutzer-ID in den Anbietereinstellungen" + invalid_access_key: "Ungültiger Zugriffsschlüssel: Überprüfen Sie Ihren Zugriffsschlüssel in den Anbietereinstellungen" + expired_credentials: "Abgelaufene Zugangsdaten: Erstellen Sie eine neue Benutzer-ID und einen neuen Zugriffsschlüssel bei Sophtron" + network_issue: "Netzwerkproblem: Überprüfen Sie Ihre Internetverbindung" + service_down: "Dienst nicht verfügbar: Die Sophtron-API ist möglicherweise vorübergehend nicht verfügbar" + bad_credentials: "Bank-Anmeldedaten: Prüfen Sie, ob Benutzername und Passwort korrekt sind" + verification_code: "Verifizierungscode: Stellen Sie sicher, dass der neueste Code vor seinem Ablauf eingegeben wurde" + institution_timeout: "Zeitüberschreitung des Instituts: Die Bank-Anmeldeseite wurde nicht rechtzeitig abgeschlossen" + unsupported_mfa: "MFA-Unterstützung: Sophtron unterstützt möglicherweise den aktuellen Verifizierungsablauf dieses Instituts nicht" + check_provider_settings: "Anbietereinstellungen prüfen" + try_again: "Erneut verbinden" + select_option: "%{type} auswählen" + subtype: "Untertyp" + type: "Typ" + sophtron_panel: + setup_instructions_title: "Einrichtungsanweisungen:" + setup_instructions: + step_1_html: 'Besuchen Sie Sophtron, um Ihre API-Zugangsdaten zu erhalten' + step_2: "Kopieren Sie Ihre Benutzer-ID und Ihren Zugriffsschlüssel aus Ihren Sophtron-Kontoeinstellungen" + step_3: "Fügen Sie die Zugangsdaten unten ein und klicken Sie auf Speichern; Sure erstellt oder verwendet Ihre Sophtron-Kunden-ID automatisch" + field_descriptions_title: "Feldbeschreibungen:" + field_descriptions: + user_id_html: "Benutzer-ID: Ihre Sophtron-Benutzer-ID-Zugangsdaten" + access_key_html: "Zugriffsschlüssel: Ihre Sophtron-Zugriffsschlüssel-Zugangsdaten" + base_url_html: "Basis-URL: Die URL des Sophtron-API-Endpunkts, üblicherweise https://api.sophtron.com/api" + fields: + user_id: + label: "Benutzer-ID" + placeholder_new: "Fügen Sie Ihre Sophtron-Benutzer-ID ein" + placeholder_edit: "••••••••" + access_key: + label: "Zugriffsschlüssel" + placeholder_new: "Fügen Sie Ihren Sophtron-Zugriffsschlüssel ein" + placeholder_edit: "••••••••" + base_url: + label: "Basis-URL" + placeholder: "https://api.sophtron.com/api" + save: "Konfiguration speichern" + update: "Konfiguration aktualisieren" + syncer: + manual_sync_required: "Für dieses Institut ist eine manuelle Sophtron-Synchronisierung erforderlich; diese Konten werden während der automatischen Synchronisierung übersprungen." + importing_accounts: "Konten werden von Sophtron importiert..." + checking_account_configuration: "Kontokonfiguration wird geprüft..." + accounts_need_setup: "%{count} Konto(en) müssen eingerichtet werden" + processing_transactions: "Transaktionen für verknüpfte Konten werden verarbeitet..." + calculating_balances: "Salden für verknüpfte Konten werden berechnet..." + sophtron_entry: + processor: + unknown_transaction: "Unbekannte Transaktion" + render_connection_timeout: + timeout: "Zeitüberschreitung der Verbindung. Bitte versuchen Sie es erneut." + redirect_after_account_link: + invalid_account_names: + one: "%{count} Konto mit leerem Namen kann nicht verknüpft werden" + other: "%{count} Konten mit leeren Namen können nicht verknüpft werden" + partial_invalid: "%{created_count} Konto(en) verknüpft. %{already_linked_count} bereits verknüpft, %{invalid_count} hatten ungültige Namen." + partial_success: "%{created_count} Konto(en) verknüpft. %{already_linked_count} Konto(en) waren bereits verknüpft." + success: + one: "%{count} Konto erfolgreich verknüpft." + other: "%{count} Konten erfolgreich verknüpft." + all_already_linked: + one: "Das ausgewählte Konto ist bereits verknüpft" + other: "Alle %{count} ausgewählten Konten sind bereits verknüpft" + link_failed: "Konten konnten nicht verknüpft werden" + start_manual_sync: + already_running: "Eine Synchronisierung läuft bereits." + no_linked_accounts: "Keine verknüpften Konten zum Synchronisieren verfügbar." + api_error: "API-Fehler: %{message}" + start_manual_sync_for_account: + failed: "Konto konnte nicht synchronisiert werden" diff --git a/config/locales/views/sophtron_items/es.yml b/config/locales/views/sophtron_items/es.yml new file mode 100644 index 000000000..f1567857d --- /dev/null +++ b/config/locales/views/sophtron_items/es.yml @@ -0,0 +1,313 @@ +--- +es: + sophtron_items: + defaults: + name: Conexión de Sophtron + new: + title: Conectar Sophtron + user_id: ID de usuario + user_id_placeholder: pega tu ID de usuario de Sophtron + access_key: Clave de acceso + access_key_placeholder: pega tu clave de acceso de Sophtron + connect: Conectar + cancel: Cancelar + create: + success: Conexión de Sophtron creada correctamente + destroy: + success: Conexión de Sophtron eliminada + update: + success: ¡Conexión de Sophtron actualizada correctamente! Tus cuentas se están reconectando. + errors: + blank_user_id: Introduce un ID de usuario de Sophtron. + invalid_user_id: ID de usuario no válido. Comprueba que has copiado el ID de usuario completo de Sophtron. + user_id_compromised: El ID de usuario puede estar comprometido, caducado o ya usado. Crea uno nuevo. + blank_access_key: Introduce una clave de acceso de Sophtron. + invalid_access_key: Clave de acceso no válida. Comprueba que has copiado la clave de acceso completa de Sophtron. + access_key_compromised: La clave de acceso puede estar comprometida, caducada o ya usada. Crea una nueva. + update_failed: "No se pudo actualizar la conexión: %{message}" + unexpected: Se ha producido un error inesperado. Inténtalo de nuevo o ponte en contacto con el soporte. + edit: + user_id: + label: "ID de usuario de Sophtron:" + placeholder: "Pega aquí tu ID de usuario de Sophtron..." + help_text: "El ID de usuario debe ser una cadena larga que empieza por letras y números" + access_key: + label: "Clave de acceso de Sophtron:" + placeholder: "Pega aquí tu clave de acceso de Sophtron..." + help_text: "La clave de acceso debe ser una cadena larga que empieza por letras y números" + index: + title: Conexiones de Sophtron + loading: + loading_message: Cargando cuentas de Sophtron... + loading_title: Cargando + link_accounts: + all_already_linked: + one: "La cuenta seleccionada (%{names}) ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas: %{names}" + api_error: "Error de conexión con la API" + invalid_account_names: + one: "No se puede vincular una cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + link_failed: No se pudieron vincular las cuentas + no_accounts_selected: Selecciona al menos una cuenta + partial_invalid: "Se vincularon %{created_count} cuenta(s) correctamente, %{already_linked_count} ya estaban vinculadas, %{invalid_count} cuenta(s) tenían nombres no válidos" + partial_success: "Se vincularon %{created_count} cuenta(s) correctamente. %{already_linked_count} cuenta(s) ya estaban vinculadas: %{already_linked_names}" + success: + one: "Se ha vinculado %{count} cuenta correctamente" + other: "Se han vinculado %{count} cuentas correctamente" + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + connect: + cancel: Cancelar + captcha: Captcha + connect: Conectar + institution_search_label: Entidad + institution_search_placeholder: Buscar por nombre del banco + no_institutions: No se han encontrado entidades coincidentes. + password: Contraseña + search: Buscar + search_too_short: Introduce al menos dos caracteres para buscar. + title: Conectar entidad de Sophtron + username: Nombre de usuario + connect_institution: + api_error: "La conexión con Sophtron ha fallado: %{message}" + missing_parameters: Selecciona una entidad e introduce las credenciales de acceso de tu banco. + connection_status: + api_error: "Error de conexión con la API: %{message}" + attempt: "Intento %{attempt} de %{max}" + check_again: Volver a comprobar + failed: Sophtron no pudo completar esta conexión con la entidad. + failed_timeout: Sophtron agotó el tiempo de espera mientras la entidad completaba el acceso. + timeout: Sophtron no terminó de conectarse en el tiempo previsto. Puedes volver a comprobarlo o intentar reconectar más tarde. + title: Conectando con Sophtron + waiting: Sophtron todavía se está conectando con tu entidad. + mfa: + captcha: Texto del captcha + captcha_alt: Captcha de Sophtron + phone_confirmed: He confirmado por teléfono + submit: Enviar + title: Verificación de Sophtron + token: Código de verificación + submit_mfa: + api_error: "La verificación ha fallado: %{message}" + invalid_security_answers: Faltan las respuestas de seguridad o son demasiado largas. + unknown_challenge: Paso de verificación de Sophtron desconocido. + sophtron_item: + accounts_need_setup: Las cuentas necesitan configuración + automatic_sync: Usar sincronización automática + delete: Eliminar conexión + deletion_in_progress: eliminación en curso... + error: Error + no_accounts_description: Esta conexión aún no tiene cuentas vinculadas. + no_accounts_title: Sin cuentas + manual_sync: Sincronización manual + manual_sync_action: Requerir sincronización manual + manual_sync_action_for: "Requerir sincronización manual para %{institution}" + automatic_sync_for: "Usar sincronización automática para %{institution}" + setup_action: Configurar nuevas cuentas + setup_description: "%{linked} de %{total} cuentas vinculadas. Elige los tipos de cuenta para tus cuentas de Sophtron recién importadas." + setup_needed: Nuevas cuentas listas para configurar + status: "Sincronizado hace %{timestamp}" + status_never: Nunca sincronizado + status_with_summary: "Sincronizado hace %{timestamp} • %{summary}" + sync_now: Sincronizar ahora + syncing: Sincronizando... + total: Total + unlinked: Sin vincular + preload_accounts: + preload_accounts: precargar cuentas + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + select_accounts: + accounts_selected: cuentas seleccionadas + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + cancel: Cancelar + configure_name_in_sophtron: "No se puede importar: configura el nombre de la cuenta en Sophtron" + description: Selecciona las cuentas que quieres vincular a tu cuenta de %{product_name}. + link_accounts: Vincular cuentas seleccionadas + no_accounts_found: No se han encontrado cuentas. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_credentials_configured: "Configura primero tu ID de usuario y tu clave de acceso de la API de Sophtron en los Ajustes del proveedor." + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + no_name_placeholder: "(Sin nombre)" + title: Seleccionar cuentas de Sophtron + select_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + all_accounts_already_linked: Todas las cuentas de Sophtron ya están vinculadas + api_error: "Error de conexión con la API" + cancel: Cancelar + configure_name_in_sophtron: "No se puede importar: configura el nombre de la cuenta en Sophtron" + description: Selecciona una cuenta de Sophtron para vincularla a esta cuenta. Las transacciones se sincronizarán y se eliminarán los duplicados automáticamente. + link_account: Vincular cuenta + no_account_specified: No se ha especificado ninguna cuenta + no_accounts_found: No se han encontrado cuentas de Sophtron. Comprueba la configuración de tu clave de API. + no_access_key: La clave de acceso de Sophtron no está configurada. Configúrala en los Ajustes. + no_user_id: El ID de usuario de Sophtron no está configurado. Configúralo en los Ajustes. + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + no_name_placeholder: "(Sin nombre)" + title: "Vincular %{account_name} con Sophtron" + unexpected_error: "Se ha producido un error inesperado" + link_existing_account: + account_already_linked: Esta cuenta ya está vinculada a un proveedor + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + invalid_account_name: No se puede vincular una cuenta con el nombre en blanco + sophtron_account_already_linked: Esta cuenta de Sophtron ya está vinculada a otra cuenta + sophtron_account_not_found: No se ha encontrado la cuenta de Sophtron + missing_parameters: Faltan parámetros obligatorios + no_institution_connected: Conecta primero una entidad bancaria con Sophtron. + success: "%{account_name} vinculada correctamente con Sophtron" + setup_accounts: + account_type_label: "Tipo de cuenta:" + all_accounts_linked: "Todas tus cuentas de Sophtron ya se han configurado." + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + fetch_failed: "No se pudieron obtener las cuentas" + no_accounts_to_setup: "No hay cuentas para configurar" + no_access_key: "La clave de acceso de Sophtron no está configurada. Comprueba los ajustes de tu conexión." + no_user_id: "El ID de usuario de Sophtron no está configurado. Comprueba los ajustes de tu conexión." + no_institution_connected: "La entidad de Sophtron aún no está conectada." + account_types: + skip: Omitir esta cuenta + depository: Cuenta corriente o de ahorros + credit_card: Tarjeta de crédito + investment: Cuenta de inversión + loan: Préstamo o hipoteca + other_asset: Otro activo + subtype_labels: + depository: "Subtipo de cuenta:" + credit_card: "" + investment: "Tipo de inversión:" + loan: "Tipo de préstamo:" + other_asset: "" + subtype_messages: + credit_card: "Las tarjetas de crédito se configurarán automáticamente como cuentas de tarjeta de crédito." + other_asset: "No se necesitan opciones adicionales para Otros activos." + balance: Saldo + cancel: Cancelar + choose_account_type: "Elige el tipo de cuenta correcto para cada cuenta de Sophtron:" + create_accounts: Crear cuentas + creating_accounts: Creando cuentas... + historical_data_range: "Rango de datos históricos:" + subtitle: Elige los tipos de cuenta correctos para tus cuentas importadas + sync_start_date_help: Selecciona hasta qué fecha quieres sincronizar el historial de transacciones. Hay un máximo de 3 años de historial disponible. + sync_start_date_label: "Empezar a sincronizar transacciones desde:" + title: Configura tus cuentas de Sophtron + complete_account_setup: + all_skipped: "Se omitieron todas las cuentas. No se ha creado ninguna cuenta." + creation_failed: "No se pudieron crear las cuentas" + api_error: "Error de conexión con la API" + unexpected_error: "Se ha producido un error inesperado" + no_accounts: "No hay cuentas para configurar." + success: "Se han creado %{count} cuenta(s) correctamente." + sync: + already_running: Ya hay una sincronización manual de Sophtron en curso. + api_error: "La sincronización manual de Sophtron ha fallado: %{message}" + failed: La sincronización manual de Sophtron ha fallado + no_linked_accounts: Esta entidad de Sophtron no tiene cuentas vinculadas para sincronizar. + processing_failed: La sincronización manual de Sophtron no pudo procesar las transacciones actualizadas. + success: Sincronización iniciada + toggle_manual_sync: + success_disabled: La entidad de Sophtron se sincronizará automáticamente. + success_enabled: La entidad de Sophtron ahora requiere sincronización manual. + manual_sync_complete: + close: Cerrar + description: Los saldos de las cuentas terminarán de actualizarse en segundo plano. + message: Las transacciones se descargaron tras la verificación de Sophtron. + title: Sincronización de Sophtron iniciada + sophtron_setup_required: + title: Configuración de Sophtron necesaria + message: > + Para completar la configuración de tu conexión de Sophtron, ve a la página de Ajustes del proveedor y sigue las instrucciones para autorizar y configurar tu conexión de Sophtron. + go_to_provider_settings: Ir a los Ajustes del proveedor + heading: "ID de usuario y clave de acceso no configurados" + description: "Antes de poder vincular cuentas de Sophtron, debes configurar tu ID de usuario y tu clave de acceso de Sophtron." + setup_steps_title: "Pasos de configuración:" + step_1_html: "Ve a Ajustes → Proveedores de sincronización bancaria" + step_2_html: "Busca la sección Sophtron" + step_3_html: "Introduce tu ID de usuario y tu clave de acceso de Sophtron" + step_4: "Vuelve aquí para vincular tus cuentas" + api_error: + title: "Error de conexión con Sophtron" + unable_to_connect: "No se puede conectar con Sophtron" + institution_unable_to_connect: "No se puede conectar con la entidad" + common_issues_title: "Problemas habituales:" + incorrect_user_id: "ID de usuario incorrecto: Verifica tu ID de usuario en los Ajustes del proveedor" + invalid_access_key: "Clave de acceso no válida: Comprueba tu clave de acceso en los Ajustes del proveedor" + expired_credentials: "Credenciales caducadas: Genera un nuevo ID de usuario y una nueva clave de acceso en Sophtron" + network_issue: "Problema de red: Comprueba tu conexión a internet" + service_down: "Servicio no disponible: La API de Sophtron puede estar temporalmente no disponible" + bad_credentials: "Credenciales bancarias: Comprueba que el nombre de usuario y la contraseña sean correctos" + verification_code: "Código de verificación: Asegúrate de introducir el código más reciente antes de que caduque" + institution_timeout: "Tiempo de espera de la entidad agotado: La página de acceso del banco no terminó a tiempo" + unsupported_mfa: "Compatibilidad con MFA: Es posible que Sophtron no admita el flujo de verificación actual de esta entidad" + check_provider_settings: "Comprobar los Ajustes del proveedor" + try_again: "Volver a intentar la conexión" + select_option: "Seleccionar %{type}" + subtype: "subtipo" + type: "tipo" + sophtron_panel: + setup_instructions_title: "Instrucciones de configuración:" + setup_instructions: + step_1_html: 'Visita Sophtron para obtener tus credenciales de API' + step_2: "Copia tu ID de usuario y tu clave de acceso desde los ajustes de tu cuenta de Sophtron" + step_3: "Pega las credenciales abajo y haz clic en Guardar; Sure creará o reutilizará tu ID de cliente de Sophtron automáticamente" + field_descriptions_title: "Descripciones de los campos:" + field_descriptions: + user_id_html: "ID de usuario: Tu credencial de ID de usuario de Sophtron" + access_key_html: "Clave de acceso: Tu credencial de clave de acceso de Sophtron" + base_url_html: "URL base: La URL del endpoint de la API de Sophtron, normalmente https://api.sophtron.com/api" + fields: + user_id: + label: "ID de usuario" + placeholder_new: "Pega tu ID de usuario de Sophtron" + placeholder_edit: "••••••••" + access_key: + label: "Clave de acceso" + placeholder_new: "Pega tu clave de acceso de Sophtron" + placeholder_edit: "••••••••" + base_url: + label: "URL base" + placeholder: "https://api.sophtron.com/api" + save: "Guardar configuración" + update: "Actualizar configuración" + syncer: + manual_sync_required: "Para esta entidad se requiere una sincronización manual de Sophtron; esas cuentas se omiten durante la sincronización automática." + importing_accounts: "Importando cuentas desde Sophtron..." + checking_account_configuration: "Comprobando la configuración de la cuenta..." + accounts_need_setup: "%{count} cuenta(s) necesitan configuración" + processing_transactions: "Procesando transacciones de las cuentas vinculadas..." + calculating_balances: "Calculando saldos de las cuentas vinculadas..." + sophtron_entry: + processor: + unknown_transaction: "Transacción desconocida" + render_connection_timeout: + timeout: "Se ha agotado el tiempo de conexión. Inténtalo de nuevo." + redirect_after_account_link: + invalid_account_names: + one: "No se puede vincular %{count} cuenta con el nombre en blanco" + other: "No se pueden vincular %{count} cuentas con los nombres en blanco" + partial_invalid: "Se vincularon %{created_count} cuenta(s). %{already_linked_count} ya estaban vinculadas, %{invalid_count} tenían nombres no válidos." + partial_success: "Se vincularon %{created_count} cuenta(s). %{already_linked_count} cuenta(s) ya estaban vinculadas." + success: + one: "Se ha vinculado %{count} cuenta correctamente." + other: "Se han vinculado %{count} cuentas correctamente." + all_already_linked: + one: "La cuenta seleccionada ya está vinculada" + other: "Las %{count} cuentas seleccionadas ya están vinculadas" + link_failed: "No se pudieron vincular las cuentas" + start_manual_sync: + already_running: "Ya hay una sincronización en curso." + no_linked_accounts: "No hay cuentas vinculadas disponibles para sincronizar." + api_error: "Error de la API: %{message}" + start_manual_sync_for_account: + failed: "No se pudo sincronizar la cuenta" diff --git a/config/locales/views/splits/de.yml b/config/locales/views/splits/de.yml new file mode 100644 index 000000000..79ea40ab9 --- /dev/null +++ b/config/locales/views/splits/de.yml @@ -0,0 +1,47 @@ +--- +de: + splits: + new: + title: Transaktion aufteilen + description: Teilen Sie diese Transaktion in mehrere Einträge mit unterschiedlichen Kategorien und Beträgen auf. + submit: Transaktion aufteilen + cancel: Abbrechen + add_row: Aufteilung hinzufügen + remove_row: Entfernen + remaining: Verbleibend + amounts_must_match: Die Teilbeträge müssen dem ursprünglichen Transaktionsbetrag entsprechen. + name_label: Name + name_placeholder: Name der Aufteilung + amount_label: Betrag + category_label: Kategorie + uncategorized: "(nicht kategorisiert)" + original_name: "Name:" + original_date: "Datum:" + original_amount: "Betrag" + split_number: "Aufteilung #%{number}" + create: + success: Transaktion erfolgreich aufgeteilt + not_splittable: Diese Transaktion kann nicht aufgeteilt werden. + destroy: + success: Aufteilung der Transaktion erfolgreich aufgehoben + show: + title: Aufgeteilte Einträge + description: Diese Transaktion wurde in die folgenden Einträge aufgeteilt. + button_title: Transaktion aufteilen + button_description: Teilen Sie diese Transaktion in mehrere Einträge mit unterschiedlichen Kategorien und Beträgen auf. + button: Aufteilen + unsplit_title: Aufteilung aufheben + unsplit_button: Aufteilung aufheben + unsplit_confirm: Dadurch werden alle aufgeteilten Einträge entfernt und die ursprüngliche Transaktion wiederhergestellt. + edit: + title: Aufteilung bearbeiten + description: Bearbeiten Sie die aufgeteilten Einträge für diese Transaktion. + submit: Aufteilung aktualisieren + not_split: Diese Transaktion ist nicht aufgeteilt. + update: + success: Aufteilung erfolgreich aktualisiert + child: + title: Teil einer Aufteilung + description: Dieser Eintrag ist Teil einer aufgeteilten Transaktion. + edit_split: Aufteilung bearbeiten + unsplit: Aufteilung aufheben diff --git a/config/locales/views/splits/es.yml b/config/locales/views/splits/es.yml new file mode 100644 index 000000000..dc378a8c5 --- /dev/null +++ b/config/locales/views/splits/es.yml @@ -0,0 +1,47 @@ +--- +es: + splits: + new: + title: Dividir transacción + description: Divide esta transacción en varias entradas con categorías e importes diferentes. + submit: Dividir transacción + cancel: Cancelar + add_row: Añadir división + remove_row: Eliminar + remaining: Restante + amounts_must_match: Los importes de las divisiones deben sumar el importe original de la transacción. + name_label: Nombre + name_placeholder: Nombre de la división + amount_label: Importe + category_label: Categoría + uncategorized: "(sin categoría)" + original_name: "Nombre:" + original_date: "Fecha:" + original_amount: "Importe" + split_number: "División n.º %{number}" + create: + success: Transacción dividida correctamente + not_splittable: Esta transacción no se puede dividir. + destroy: + success: División de la transacción deshecha correctamente + show: + title: Entradas divididas + description: Esta transacción se ha dividido en las siguientes entradas. + button_title: Dividir transacción + button_description: Divide esta transacción en varias entradas con categorías e importes diferentes. + button: Dividir + unsplit_title: Deshacer división + unsplit_button: Deshacer división + unsplit_confirm: Esto eliminará todas las entradas divididas y restaurará la transacción original. + edit: + title: Editar división + description: Modifica las entradas divididas de esta transacción. + submit: Actualizar división + not_split: Esta transacción no está dividida. + update: + success: División actualizada correctamente + child: + title: Parte de una división + description: Esta entrada forma parte de una transacción dividida. + edit_split: Editar división + unsplit: Deshacer división diff --git a/config/locales/views/transfer_matches/de.yml b/config/locales/views/transfer_matches/de.yml new file mode 100644 index 000000000..63b930aa6 --- /dev/null +++ b/config/locales/views/transfer_matches/de.yml @@ -0,0 +1,24 @@ +--- +de: + transfer_matches: + create: + success: Übertragung erstellt + new: + header: + title: Übertragung oder Zahlung zuordnen + subtitle: Ordnen Sie die entsprechende Transaktion in einem anderen Konto zu oder erstellen Sie eine, falls keine vorhanden ist. + from_account: Von Konto + from_account_named: "Von Konto: %{name}" + to_account: Zu Konto + to_account_named: "Zu Konto: %{name}" + outflow_transaction: Abgangstransaktion + inflow_transaction: Zugangstransaktion + create_transfer_match: Übertragungszuordnung erstellen + matching_fields: + select_method: Wählen Sie eine Methode zum Zuordnen Ihrer Transaktionen. + match_existing_recommended: Vorhandene Transaktion zuordnen (empfohlen) + create_new_transaction: Neue Transaktion erstellen + matching_method: Zuordnungsmethode + matching_transaction: Zuzuordnende Transaktion + target_account: Zielkonto + no_matching_transactions: Wir konnten in Ihren anderen Konten keine zuzuordnenden Transaktionen finden. Bitte wählen Sie ein Konto aus, und wir erstellen für Sie eine neue Zugangstransaktion. diff --git a/config/routes.rb b/config/routes.rb index fb4355deb..53bdc864a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -536,6 +536,10 @@ Rails.application.routes.draw do post :preflight, on: :collection get :rows, on: :member end + resources :import_sessions, only: [ :show, :create ] do + post :chunks, on: :member, action: :create_chunk + post :publish, on: :member + end resource :usage, only: [ :show ], controller: :usage resource :balance_sheet, only: [ :show ], controller: :balance_sheet resource :family_settings, only: [ :show ], controller: :family_settings diff --git a/db/migrate/20260513013000_create_import_sessions.rb b/db/migrate/20260513013000_create_import_sessions.rb new file mode 100644 index 000000000..743d7f4bd --- /dev/null +++ b/db/migrate/20260513013000_create_import_sessions.rb @@ -0,0 +1,78 @@ +class CreateImportSessions < ActiveRecord::Migration[7.2] + def change + create_table :import_sessions, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :import_type, null: false, default: "SureImport" + t.string :status, null: false, default: "pending" + t.string :client_session_id, limit: 255 + t.integer :expected_chunks + t.jsonb :summary, null: false, default: {} + t.jsonb :error_details, null: false, default: {} + + t.timestamps + + t.index [ :family_id, :client_session_id ], + unique: true, + where: "client_session_id IS NOT NULL", + name: "idx_import_sessions_on_family_client_session" + t.index [ :family_id, :status ] + t.index [ :id, :family_id ], unique: true, name: "idx_import_sessions_on_id_family" + t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive" + t.check_constraint "client_session_id IS NULL OR btrim(client_session_id) <> ''", + name: "chk_import_sessions_client_session_id_present" + t.check_constraint "import_type = 'SureImport'", name: "chk_import_sessions_import_type" + t.check_constraint "status IN ('pending', 'importing', 'complete', 'failed')", name: "chk_import_sessions_status" + t.check_constraint "jsonb_typeof(summary) = 'object'", name: "chk_import_sessions_summary_object" + t.check_constraint "jsonb_typeof(error_details) = 'object'", name: "chk_import_sessions_error_details_object" + end + + create_table :import_source_mappings, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.references :import_session, null: false, type: :uuid + t.string :source_type, null: false, limit: 64 + t.string :source_id, null: false, limit: 255 + t.references :target, polymorphic: true, null: false, type: :uuid, + index: { name: "idx_import_source_mappings_on_target" } + + t.timestamps + + t.index [ :import_session_id, :source_type, :source_id ], + unique: true, + name: "index_import_source_mappings_on_session_type_and_source" + t.index [ :family_id, :source_type, :source_id ], name: "idx_import_source_mappings_on_family_source" + t.check_constraint "btrim(source_type) <> ''", name: "chk_import_source_mappings_source_type_present" + t.check_constraint "source_type IN ('Account', 'Category', 'Tag', 'Merchant', 'RecurringTransaction', 'Transaction', 'Budget', 'Security', 'Rule')", + name: "chk_import_source_mappings_source_type" + t.check_constraint "btrim(source_id) <> ''", name: "chk_import_source_mappings_source_id_present" + t.check_constraint "btrim(target_type) <> ''", name: "chk_import_source_mappings_target_type_present" + t.check_constraint "target_type IN ('Account', 'Category', 'Tag', 'Merchant', 'RecurringTransaction', 'Transaction', 'Budget', 'Security', 'Rule')", + name: "chk_import_source_mappings_target_type" + end + + add_foreign_key :import_source_mappings, :import_sessions, + column: [ :import_session_id, :family_id ], primary_key: [ :id, :family_id ], + on_delete: :cascade, name: "fk_import_source_mappings_session_family" + + add_reference :imports, :import_session, type: :uuid + add_column :imports, :sequence, :integer + add_column :imports, :client_chunk_id, :string, limit: 255 + add_column :imports, :checksum, :string, limit: 64 + add_column :imports, :summary, :jsonb, null: false, default: {} + add_column :imports, :error_details, :jsonb, null: false, default: {} + + add_index :imports, [ :import_session_id, :sequence ], unique: true, + where: "import_session_id IS NOT NULL AND sequence IS NOT NULL", name: "idx_imports_on_session_sequence" + add_index :imports, [ :import_session_id, :client_chunk_id ], unique: true, + where: "import_session_id IS NOT NULL AND client_chunk_id IS NOT NULL", name: "idx_imports_on_session_client_chunk" + add_foreign_key :imports, :import_sessions, + column: [ :import_session_id, :family_id ], primary_key: [ :id, :family_id ], + on_delete: :cascade, name: "fk_imports_session_family" + add_check_constraint :imports, "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive" + add_check_constraint :imports, "client_chunk_id IS NULL OR btrim(client_chunk_id) <> ''", name: "chk_imports_client_chunk_id_present" + add_check_constraint :imports, "checksum IS NULL OR length(checksum) = 64", name: "chk_imports_checksum_sha256_length" + add_check_constraint :imports, "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present" + add_check_constraint :imports, "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present" + add_check_constraint :imports, "jsonb_typeof(summary) = 'object'", name: "chk_imports_summary_object" + add_check_constraint :imports, "jsonb_typeof(error_details) = 'object'", name: "chk_imports_error_details_object" + end +end diff --git a/db/migrate/20260531213000_add_disabled_at_to_accounts.rb b/db/migrate/20260531213000_add_disabled_at_to_accounts.rb new file mode 100644 index 000000000..4f33e08b8 --- /dev/null +++ b/db/migrate/20260531213000_add_disabled_at_to_accounts.rb @@ -0,0 +1,16 @@ +class AddDisabledAtToAccounts < ActiveRecord::Migration[7.2] + def change + add_column :accounts, :disabled_at, :datetime + + reversible do |dir| + dir.up do + execute <<~SQL.squish + UPDATE accounts + SET disabled_at = updated_at + WHERE status = 'disabled' + AND disabled_at IS NULL + SQL + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 513bd1abd..c055adbfc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do +ActiveRecord::Schema[7.2].define(version: 2026_05_31_213000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -117,6 +117,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "institution_domain" t.text "notes" t.uuid "owner_id" + t.datetime "disabled_at" t.integer "account_providers_count", default: 0, null: false t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" @@ -982,6 +983,49 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.check_constraint "source_row_number > 0", name: "chk_import_rows_source_row_number_positive" end + create_table "import_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "import_type", default: "SureImport", null: false + t.string "status", default: "pending", null: false + t.string "client_session_id", limit: 255 + t.integer "expected_chunks" + t.jsonb "summary", default: {}, null: false + t.jsonb "error_details", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "client_session_id"], name: "idx_import_sessions_on_family_client_session", unique: true, where: "(client_session_id IS NOT NULL)" + t.index ["family_id", "status"], name: "index_import_sessions_on_family_id_and_status" + t.index ["family_id"], name: "index_import_sessions_on_family_id" + t.index ["id", "family_id"], name: "idx_import_sessions_on_id_family", unique: true + t.check_constraint "client_session_id IS NULL OR btrim(client_session_id::text) <> ''::text", name: "chk_import_sessions_client_session_id_present" + t.check_constraint "expected_chunks IS NULL OR expected_chunks > 0", name: "chk_import_sessions_expected_chunks_positive" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_import_sessions_error_details_object" + t.check_constraint "import_type::text = 'SureImport'::text", name: "chk_import_sessions_import_type" + t.check_constraint "status::text = ANY (ARRAY['pending'::character varying, 'importing'::character varying, 'complete'::character varying, 'failed'::character varying]::text[])", name: "chk_import_sessions_status" + t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_import_sessions_summary_object" + end + + create_table "import_source_mappings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.uuid "import_session_id", null: false + t.string "source_type", limit: 64, null: false + t.string "source_id", limit: 255, null: false + t.string "target_type", null: false + t.uuid "target_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "source_type", "source_id"], name: "idx_import_source_mappings_on_family_source" + t.index ["family_id"], name: "index_import_source_mappings_on_family_id" + t.index ["import_session_id", "source_type", "source_id"], name: "index_import_source_mappings_on_session_type_and_source", unique: true + t.index ["import_session_id"], name: "index_import_source_mappings_on_import_session_id" + t.index ["target_type", "target_id"], name: "idx_import_source_mappings_on_target" + t.check_constraint "btrim(source_id::text) <> ''::text", name: "chk_import_source_mappings_source_id_present" + t.check_constraint "source_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_source_type" + t.check_constraint "btrim(source_type::text) <> ''::text", name: "chk_import_source_mappings_source_type_present" + t.check_constraint "target_type::text = ANY (ARRAY['Account'::character varying, 'Category'::character varying, 'Tag'::character varying, 'Merchant'::character varying, 'RecurringTransaction'::character varying, 'Transaction'::character varying, 'Budget'::character varying, 'Security'::character varying, 'Rule'::character varying]::text[])", name: "chk_import_source_mappings_target_type" + t.check_constraint "btrim(target_type::text) <> ''::text", name: "chk_import_source_mappings_target_type_present" + end + create_table "imports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.jsonb "column_mappings" t.string "status" @@ -1021,8 +1065,24 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.uuid "account_statement_id" t.jsonb "expected_record_counts", default: {}, null: false t.jsonb "readback_verification", default: {}, null: false + t.uuid "import_session_id" + t.integer "sequence" + t.string "client_chunk_id", limit: 255 + t.string "checksum", limit: 64 + t.jsonb "summary", default: {}, null: false + t.jsonb "error_details", default: {}, null: false t.index ["account_statement_id"], name: "index_imports_on_account_statement_id" t.index ["family_id"], name: "index_imports_on_family_id" + t.index ["import_session_id", "client_chunk_id"], name: "idx_imports_on_session_client_chunk", unique: true, where: "((import_session_id IS NOT NULL) AND (client_chunk_id IS NOT NULL))" + t.index ["import_session_id", "sequence"], name: "idx_imports_on_session_sequence", unique: true, where: "((import_session_id IS NOT NULL) AND (sequence IS NOT NULL))" + t.index ["import_session_id"], name: "index_imports_on_import_session_id" + t.check_constraint "checksum IS NULL OR length(checksum::text) = 64", name: "chk_imports_checksum_sha256_length" + t.check_constraint "client_chunk_id IS NULL OR btrim(client_chunk_id::text) <> ''::text", name: "chk_imports_client_chunk_id_present" + t.check_constraint "jsonb_typeof(error_details) = 'object'::text", name: "chk_imports_error_details_object" + t.check_constraint "import_session_id IS NULL OR checksum IS NOT NULL", name: "chk_imports_session_checksum_present" + t.check_constraint "import_session_id IS NULL OR sequence IS NOT NULL", name: "chk_imports_session_sequence_present" + t.check_constraint "jsonb_typeof(summary) = 'object'::text", name: "chk_imports_summary_object" + t.check_constraint "sequence IS NULL OR sequence > 0", name: "chk_imports_session_sequence_positive" end create_table "indexa_capital_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -2043,8 +2103,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" add_foreign_key "impersonation_sessions", "users", column: "impersonator_id" add_foreign_key "import_rows", "imports" + add_foreign_key "import_sessions", "families" + add_foreign_key "import_source_mappings", "families" + add_foreign_key "import_source_mappings", "import_sessions", column: ["import_session_id", "family_id"], primary_key: ["id", "family_id"], name: "fk_import_source_mappings_session_family", on_delete: :cascade add_foreign_key "imports", "account_statements", on_delete: :nullify add_foreign_key "imports", "families" + add_foreign_key "imports", "import_sessions", column: ["import_session_id", "family_id"], primary_key: ["id", "family_id"], name: "fk_imports_session_family", on_delete: :cascade add_foreign_key "indexa_capital_accounts", "indexa_capital_items" add_foreign_key "indexa_capital_items", "families" add_foreign_key "invitations", "families" diff --git a/design/tokens/sure.tokens.json b/design/tokens/sure.tokens.json index bf36e8743..82b4a16fe 100644 --- a/design/tokens/sure.tokens.json +++ b/design/tokens/sure.tokens.json @@ -35,7 +35,7 @@ "container-inset": { "$value": "{color.gray.50}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.800}" } }, "container-inset-hover": { "$value": "{color.gray.100}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, "nav-indicator": { "$value": "{color.black}", "$type": "color", "$extensions": { "sure.dark": "{color.white}" } }, - "toggle-track": { "$value": "{color.gray.100}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, + "toggle-track": { "$value": "{color.gray.300}", "$type": "color", "$extensions": { "sure.dark": "{color.gray.700}" } }, "destructive-subtle": { "$value": "{color.red.200}", "$type": "color", "$extensions": { "sure.dark": "{color.red.800}" } }, "gray": { @@ -285,7 +285,7 @@ "text-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.white}" } }, "text-inverse": { "$type": "utility", "$value": "{color.white}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.900}" } }, "text-secondary": { "$type": "utility", "$value": "{color.gray.500}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.300}" } }, - "text-subdued": { "$type": "utility", "$value": "{color.gray.400}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.500}" } }, + "text-subdued": { "$type": "utility", "$value": "{color.gray.400}", "$extensions": { "sure.utility": { "prefix": "text" }, "sure.dark": "{color.gray.400}" } }, "shadow-border-xs": { "$type": "utility", @@ -328,12 +328,12 @@ } }, - "border-primary": { "$type": "utility", "$value": "{color.alpha-black.300}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.400}" } }, - "border-secondary": { "$type": "utility", "$value": "{color.alpha-black.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.300}" } }, + "border-primary": { "$type": "utility", "$value": "{color.alpha-black.300}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.500}" } }, + "border-secondary": { "$type": "utility", "$value": "{color.alpha-black.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.400}" } }, "border-divider": { "$type": "utility", "$value": "border-tertiary" }, - "border-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.100}" } }, + "border-subdued": { "$type": "utility", "$value": "{color.alpha-black.50}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-white.200}" } }, "border-solid": { "$type": "utility", "$value": "{color.black}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.white}" } }, - "border-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } }, + "border-destructive": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.red.400}" } }, "border-inverse": { "$type": "utility", "$value": "{color.alpha-white.200}", "$extensions": { "sure.utility": { "prefix": "border" }, "sure.dark": "{color.alpha-black.300}" } }, "button-bg-primary": { "$type": "utility", "$value": "{color.gray.900}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.white}" } }, @@ -343,8 +343,8 @@ "button-bg-secondary-strong": { "$type": "utility", "$value": "{color.gray.200}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, "button-bg-secondary-strong-hover": { "$type": "utility", "$value": "{color.gray.300}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.600}" } }, "button-bg-disabled": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, - "button-bg-destructive": { "$type": "utility", "$value": "{color.red.500}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.400}" } }, - "button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } }, + "button-bg-destructive": { "$type": "utility", "$value": "{color.red.600}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.400}" } }, + "button-bg-destructive-hover": { "$type": "utility", "$value": "{color.red.700}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.red.500}" } }, "button-bg-ghost-hover": { "$type": "utility", "$value": "{color.gray.50}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "bg-gray-800 text-inverse" } }, "button-bg-outline-hover": { "$type": "utility", "$value": "{color.gray.100}", "$extensions": { "sure.utility": { "prefix": "bg" }, "sure.dark": "{color.gray.700}" } }, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3a1adc911..a8f224d61 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2086,6 +2086,115 @@ components: properties: data: "$ref": "#/components/schemas/ImportDetail" + ImportSessionChunk: + type: object + required: + - id + - sequence + - status + - rows_count + - summary + - created_at + - updated_at + properties: + id: + type: string + format: uuid + sequence: + type: integer + minimum: 1 + client_chunk_id: + type: string + nullable: true + status: + type: string + enum: + - pending + - importing + - complete + - failed + rows_count: + type: integer + minimum: 0 + summary: + type: object + additionalProperties: + type: object + additionalProperties: + type: integer + error: + type: object + nullable: true + additionalProperties: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ImportSession: + type: object + required: + - id + - type + - status + - chunks_count + - summary + - chunks + - created_at + - updated_at + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - SureImport + status: + type: string + enum: + - pending + - importing + - complete + - failed + client_session_id: + type: string + nullable: true + expected_chunks: + type: integer + nullable: true + minimum: 1 + chunks_count: + type: integer + minimum: 0 + summary: + type: object + additionalProperties: + type: object + additionalProperties: + type: integer + error: + type: object + nullable: true + additionalProperties: true + chunks: + type: array + items: + "$ref": "#/components/schemas/ImportSessionChunk" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + ImportSessionResponse: + type: object + required: + - data + properties: + data: + "$ref": "#/components/schemas/ImportSession" ProviderConnectionInstitution: type: object required: @@ -2892,6 +3001,8 @@ components: - account_statements - family_exports - imports + - import_sessions + - import_source_mappings - import_rows - import_mappings - accounts @@ -2933,6 +3044,12 @@ components: imports: type: integer minimum: 0 + import_sessions: + type: integer + minimum: 0 + import_source_mappings: + type: integer + minimum: 0 import_rows: type: integer minimum: 0 @@ -4607,6 +4724,266 @@ paths: application/json: schema: "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions": + post: + summary: Create import session + description: Create or idempotently retrieve a multi-file SureImport session + keyed by client_session_id. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + parameters: [] + responses: + '201': + description: import session created + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: client session conflict + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: validation error + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + requestBody: + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - SureImport + description: Import session type. Only SureImport is supported. + client_session_id: + type: string + nullable: true + description: Client-provided idempotency key for the full import + session. + expected_chunks: + type: integer + minimum: 1 + nullable: true + description: Expected number of ordered chunks before publish is + allowed. + "/api/v1/import_sessions/{id}": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + get: + summary: Retrieve import session + description: Retrieve import session status, chunk status, per-entity summary + counts, and safe error details. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + responses: + '200': + description: import session retrieved + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '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: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions/{id}/chunks": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + post: + summary: Upload import session chunk + description: Attach an ordered Sure NDJSON chunk to an import session. Chunks + are idempotent by sequence and client_chunk_id with content verification. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - sequence + - raw_file_content + properties: + sequence: + type: integer + minimum: 1 + description: One-based chunk sequence. Earlier dependency chunks + must have lower sequence numbers. + client_chunk_id: + type: string + nullable: true + description: Client-provided idempotency key for this chunk. + raw_file_content: + type: string + description: Raw Sure NDJSON content. Each chunk is limited to 10MB. + multipart/form-data: + schema: + type: object + required: + - sequence + - file + properties: + sequence: + type: integer + minimum: 1 + description: One-based chunk sequence. Earlier dependency chunks + must have lower sequence numbers. + client_chunk_id: + type: string + nullable: true + description: Client-provided idempotency key for this chunk. + file: + type: string + format: binary + description: Multipart Sure NDJSON file upload. Each chunk is limited + to 10MB. + parameters: [] + responses: + '201': + description: chunk uploaded + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '401': + description: unauthorized + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '403': + description: insufficient scope + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: chunk conflict + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '422': + description: missing or invalid content + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + "/api/v1/import_sessions/{id}/publish": + parameters: + - name: id + in: path + required: true + description: Import session ID + schema: + type: string + post: + summary: Publish import session + description: Queue ordered chunk processing for a SureImport session. Later + chunks can reference source IDs mapped by earlier chunks. + tags: + - Import Sessions + security: + - apiKeyAuth: [] + responses: + '202': + description: import session publish queued + content: + application/json: + schema: + "$ref": "#/components/schemas/ImportSessionResponse" + '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: max_row_count_exceeded + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '409': + description: missing expected chunks + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '503': + description: enqueue failed + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" + '404': + description: import session not found + content: + application/json: + schema: + "$ref": "#/components/schemas/ErrorResponse" "/api/v1/imports": get: summary: List imports diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index bc1b4afc6..6f28c77bd 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -5,6 +5,8 @@ import 'package:provider/provider.dart'; import 'providers/auth_provider.dart'; import 'providers/accounts_provider.dart'; import 'providers/categories_provider.dart'; +import 'providers/merchants_provider.dart'; +import 'providers/tags_provider.dart'; import 'providers/transactions_provider.dart'; import 'providers/chat_provider.dart'; import 'providers/theme_provider.dart'; @@ -40,6 +42,8 @@ class SureApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()), ChangeNotifierProvider(create: (_) => CategoriesProvider()), + ChangeNotifierProvider(create: (_) => MerchantsProvider()), + ChangeNotifierProvider(create: (_) => TagsProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProxyProvider( create: (_) => AccountsProvider(), diff --git a/mobile/lib/models/merchant.dart b/mobile/lib/models/merchant.dart new file mode 100644 index 000000000..a8ef8143d --- /dev/null +++ b/mobile/lib/models/merchant.dart @@ -0,0 +1,20 @@ +class Merchant { + final String id; + final String name; + final String? type; + + Merchant({required this.id, required this.name, this.type}); + + factory Merchant.fromJson(Map json) { + final id = json['id']?.toString().trim(); + if (id == null || id.isEmpty) { + throw FormatException('Merchant response is missing id: $json'); + } + + return Merchant( + id: id, + name: json['name']?.toString() ?? '', + type: json['type']?.toString(), + ); + } +} diff --git a/mobile/lib/models/offline_transaction.dart b/mobile/lib/models/offline_transaction.dart index 0f8a877f4..1fafd40a5 100644 --- a/mobile/lib/models/offline_transaction.dart +++ b/mobile/lib/models/offline_transaction.dart @@ -1,10 +1,12 @@ +import 'dart:convert'; + import 'transaction.dart'; enum SyncStatus { - synced, // Transaction is synced with server - pending, // Transaction is waiting to be synced (create) - failed, // Last sync attempt failed - pendingDelete, // Transaction is waiting to be deleted on server + synced, // Transaction is synced with server + pending, // Transaction is waiting to be synced (create) + failed, // Last sync attempt failed + pendingDelete, // Transaction is waiting to be deleted on server } class OfflineTransaction extends Transaction { @@ -25,6 +27,13 @@ class OfflineTransaction extends Transaction { super.notes, super.categoryId, super.categoryName, + super.categoryProvided = true, + super.merchantId, + super.merchantName, + super.merchantProvided = true, + super.tagIds, + super.tagNames, + super.tagsProvided = true, this.syncStatus = SyncStatus.pending, DateTime? createdAt, DateTime? updatedAt, @@ -48,11 +57,23 @@ class OfflineTransaction extends Transaction { notes: transaction.notes, categoryId: transaction.categoryId, categoryName: transaction.categoryName, + categoryProvided: transaction.categoryProvided, + merchantId: transaction.merchantId, + merchantName: transaction.merchantName, + merchantProvided: transaction.merchantProvided, + tagIds: transaction.tagIds, + tagNames: transaction.tagNames, + tagsProvided: transaction.tagsProvided, syncStatus: syncStatus, ); } factory OfflineTransaction.fromDatabaseMap(Map map) { + final tagIds = _decodeStringList(map['tag_ids'] as String?); + final tagNames = _decodeStringList(map['tag_names'] as String?); + final tagsProvided = + map.containsKey('tag_ids') || map.containsKey('tag_names'); + return OfflineTransaction( id: map['server_id'] as String?, localId: map['local_id'] as String, @@ -65,6 +86,11 @@ class OfflineTransaction extends Transaction { notes: map['notes'] as String?, categoryId: map['category_id'] as String?, categoryName: map['category_name'] as String?, + merchantId: map['merchant_id'] as String?, + merchantName: map['merchant_name'] as String?, + tagIds: tagIds, + tagNames: tagNames, + tagsProvided: tagsProvided, syncStatus: _parseSyncStatus(map['sync_status'] as String), createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), @@ -84,6 +110,10 @@ class OfflineTransaction extends Transaction { 'notes': notes, 'category_id': categoryId, 'category_name': categoryName, + 'merchant_id': merchantId, + 'merchant_name': merchantName, + 'tag_ids': jsonEncode(tagIds), + 'tag_names': jsonEncode(tagNames), 'sync_status': _syncStatusToString(syncStatus), 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), @@ -102,6 +132,82 @@ class OfflineTransaction extends Transaction { notes: notes, categoryId: categoryId, categoryName: categoryName, + categoryProvided: categoryProvided, + merchantId: merchantId, + merchantName: merchantName, + merchantProvided: merchantProvided, + tagIds: tagIds, + tagNames: tagNames, + tagsProvided: tagsProvided, + ); + } + + Transaction toTransactionWithSubmittedUpdate({ + String? name, + String? notes, + String? categoryId, + String? merchantId, + List? tagIds, + }) { + final nextTagIds = tagIds ?? this.tagIds; + final tagNamesById = {}; + for (var i = 0; i < this.tagIds.length; i++) { + tagNamesById[this.tagIds[i]] = i < tagNames.length ? tagNames[i] : ''; + } + + final nextCategoryId = categoryId ?? this.categoryId; + final nextMerchantId = merchantId ?? this.merchantId; + + return Transaction( + id: id, + accountId: accountId, + name: name ?? this.name, + date: date, + amount: amount, + currency: currency, + nature: nature, + notes: notes ?? this.notes, + categoryId: nextCategoryId, + categoryName: nextCategoryId == this.categoryId ? categoryName : null, + categoryProvided: true, + merchantId: nextMerchantId, + merchantName: nextMerchantId == this.merchantId ? merchantName : null, + merchantProvided: true, + tagIds: nextTagIds, + tagNames: nextTagIds.map((tagId) => tagNamesById[tagId] ?? '').toList(), + tagsProvided: true, + ); + } + + OfflineTransaction mergeServerTransaction( + Transaction transaction, { + required String accountId, + }) { + return OfflineTransaction( + id: transaction.id, + localId: localId, + accountId: accountId, + name: transaction.name, + date: transaction.date, + amount: transaction.amount, + currency: transaction.currency, + nature: transaction.nature, + notes: transaction.notes, + categoryId: + transaction.categoryProvided ? transaction.categoryId : categoryId, + categoryName: transaction.categoryProvided + ? transaction.categoryName + : categoryName, + merchantId: + transaction.merchantProvided ? transaction.merchantId : merchantId, + merchantName: transaction.merchantProvided + ? transaction.merchantName + : merchantName, + tagIds: transaction.tagsProvided ? transaction.tagIds : tagIds, + tagNames: transaction.tagsProvided ? transaction.tagNames : tagNames, + syncStatus: SyncStatus.synced, + createdAt: createdAt, + updatedAt: DateTime.now(), ); } @@ -117,6 +223,13 @@ class OfflineTransaction extends Transaction { String? notes, String? categoryId, String? categoryName, + bool? categoryProvided, + String? merchantId, + String? merchantName, + bool? merchantProvided, + List? tagIds, + List? tagNames, + bool? tagsProvided, SyncStatus? syncStatus, DateTime? createdAt, DateTime? updatedAt, @@ -133,6 +246,13 @@ class OfflineTransaction extends Transaction { notes: notes ?? this.notes, categoryId: categoryId ?? this.categoryId, categoryName: categoryName ?? this.categoryName, + categoryProvided: categoryProvided ?? this.categoryProvided, + merchantId: merchantId ?? this.merchantId, + merchantName: merchantName ?? this.merchantName, + merchantProvided: merchantProvided ?? this.merchantProvided, + tagIds: tagIds ?? this.tagIds, + tagNames: tagNames ?? this.tagNames, + tagsProvided: tagsProvided ?? this.tagsProvided, syncStatus: syncStatus ?? this.syncStatus, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, @@ -158,6 +278,26 @@ class OfflineTransaction extends Transaction { } } + static List _decodeStringList(String? jsonText) { + if (jsonText == null || jsonText.isEmpty) { + return const []; + } + + try { + final decoded = jsonDecode(jsonText); + if (decoded is List) { + return decoded + .where((item) => item != null) + .map((item) => item.toString()) + .toList(); + } + } catch (_) { + return const []; + } + + return const []; + } + static String _syncStatusToString(SyncStatus status) { switch (status) { case SyncStatus.synced: diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart index e20e536b3..d6e0b6a12 100644 --- a/mobile/lib/models/transaction.dart +++ b/mobile/lib/models/transaction.dart @@ -9,6 +9,13 @@ class Transaction { final String? notes; final String? categoryId; final String? categoryName; + final bool categoryProvided; + final String? merchantId; + final String? merchantName; + final bool merchantProvided; + final List tagIds; + final List tagNames; + final bool tagsProvided; Transaction({ this.id, @@ -21,7 +28,21 @@ class Transaction { this.notes, this.categoryId, this.categoryName, - }); + bool? categoryProvided, + this.merchantId, + this.merchantName, + bool? merchantProvided, + List tagIds = const [], + List tagNames = const [], + bool? tagsProvided, + }) : tagIds = List.unmodifiable(tagIds), + tagNames = List.unmodifiable(tagNames), + categoryProvided = + categoryProvided ?? (categoryId != null || categoryName != null), + merchantProvided = + merchantProvided ?? (merchantId != null || merchantName != null), + tagsProvided = + tagsProvided ?? (tagIds.isNotEmpty || tagNames.isNotEmpty); factory Transaction.fromJson(Map json) { // Handle both API formats: @@ -37,7 +58,8 @@ class Transaction { // Handle classification (from backend) or nature (from mobile) String nature = 'expense'; if (json['classification'] != null) { - final classification = json['classification']?.toString().toLowerCase() ?? ''; + final classification = + json['classification']?.toString().toLowerCase() ?? ''; nature = classification == 'income' ? 'income' : 'expense'; } else if (json['nature'] != null) { nature = json['nature']?.toString() ?? 'expense'; @@ -46,6 +68,9 @@ class Transaction { // Parse category from API response String? categoryId; String? categoryName; + final categoryProvided = json.containsKey('category') || + json.containsKey('category_id') || + json.containsKey('category_name'); if (json['category'] != null && json['category'] is Map) { categoryId = json['category']['id']?.toString(); categoryName = json['category']['name']?.toString(); @@ -54,6 +79,51 @@ class Transaction { categoryName = json['category_name']?.toString(); } + String? merchantId; + String? merchantName; + final merchantProvided = json.containsKey('merchant') || + json.containsKey('merchant_id') || + json.containsKey('merchant_name'); + if (json['merchant'] != null && json['merchant'] is Map) { + merchantId = json['merchant']['id']?.toString(); + merchantName = json['merchant']['name']?.toString(); + } else if (json['merchant_id'] != null) { + merchantId = json['merchant_id']?.toString(); + merchantName = json['merchant_name']?.toString(); + } + + final tagIds = []; + final tagNames = []; + final tagsProvided = json.containsKey('tags') || + json.containsKey('tag_ids') || + json.containsKey('tag_names'); + if (json['tags'] is List) { + for (final tag in json['tags']) { + if (tag is Map) { + final id = tag['id']?.toString().trim(); + if (id != null && id.isNotEmpty) { + tagIds.add(id); + tagNames.add(tag['name']?.toString() ?? ''); + } + } + } + } else if (json['tag_ids'] is List) { + final rawIds = json['tag_ids'] as List; + final rawNames = + json['tag_names'] is List ? json['tag_names'] as List : const []; + for (var i = 0; i < rawIds.length; i++) { + final id = rawIds[i]?.toString().trim() ?? ''; + if (id.isNotEmpty) { + tagIds.add(id); + tagNames + .add(i < rawNames.length ? rawNames[i]?.toString() ?? '' : ''); + } + } + } + while (tagNames.length < tagIds.length) { + tagNames.add(''); + } + return Transaction( id: json['id']?.toString(), accountId: accountId, @@ -65,6 +135,13 @@ class Transaction { notes: json['notes']?.toString(), categoryId: categoryId, categoryName: categoryName, + categoryProvided: categoryProvided, + merchantId: merchantId, + merchantName: merchantName, + merchantProvided: merchantProvided, + tagIds: tagIds, + tagNames: tagNames, + tagsProvided: tagsProvided, ); } @@ -80,6 +157,10 @@ class Transaction { if (notes != null) 'notes': notes, if (categoryId != null) 'category_id': categoryId, if (categoryName != null) 'category_name': categoryName, + if (merchantId != null) 'merchant_id': merchantId, + if (merchantName != null) 'merchant_name': merchantName, + if (tagIds.isNotEmpty) 'tag_ids': tagIds, + if (tagNames.isNotEmpty) 'tag_names': tagNames, }; } diff --git a/mobile/lib/models/transaction_tag.dart b/mobile/lib/models/transaction_tag.dart new file mode 100644 index 000000000..90315fca1 --- /dev/null +++ b/mobile/lib/models/transaction_tag.dart @@ -0,0 +1,15 @@ +class TransactionTag { + final String id; + final String name; + final String? color; + + TransactionTag({required this.id, required this.name, this.color}); + + factory TransactionTag.fromJson(Map json) { + return TransactionTag( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + color: json['color']?.toString(), + ); + } +} diff --git a/mobile/lib/providers/merchants_provider.dart b/mobile/lib/providers/merchants_provider.dart new file mode 100644 index 000000000..996479646 --- /dev/null +++ b/mobile/lib/providers/merchants_provider.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import '../models/merchant.dart'; +import '../services/log_service.dart'; +import '../services/merchants_service.dart'; + +class MerchantsProvider with ChangeNotifier { + final MerchantsService _merchantsService = MerchantsService(); + final LogService _log = LogService.instance; + + List _merchants = []; + bool _isLoading = false; + String? _error; + bool _hasFetched = false; + + List get merchants => List.unmodifiable(_merchants); + bool get isLoading => _isLoading; + String? get error => _error; + bool get hasFetched => _hasFetched; + + Future fetchMerchants({ + required String accessToken, + bool forceRefresh = false, + }) async { + if (_isLoading || (_hasFetched && !forceRefresh)) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final result = await _merchantsService.getMerchants( + accessToken: accessToken, + ); + + if (result['success'] == true) { + _merchants = + (result['merchants'] as List? ?? const []).cast(); + _hasFetched = true; + _log.info( + 'MerchantsProvider', + 'Fetched ${_merchants.length} merchants', + ); + } else { + _error = result['error'] as String?; + _log.error('MerchantsProvider', 'Failed to fetch merchants: $_error'); + } + } catch (e) { + _error = 'Failed to load merchants'; + _log.error('MerchantsProvider', 'Exception fetching merchants: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void clear() { + _merchants = []; + _hasFetched = false; + _error = null; + notifyListeners(); + } +} diff --git a/mobile/lib/providers/tags_provider.dart b/mobile/lib/providers/tags_provider.dart new file mode 100644 index 000000000..944b7ff56 --- /dev/null +++ b/mobile/lib/providers/tags_provider.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; +import '../models/transaction_tag.dart'; +import '../services/log_service.dart'; +import '../services/tags_service.dart'; + +class TagsProvider with ChangeNotifier { + final TagsService _tagsService = TagsService(); + final LogService _log = LogService.instance; + + List _tags = []; + bool _isLoading = false; + String? _error; + bool _hasFetched = false; + + List get tags => List.unmodifiable(_tags); + bool get isLoading => _isLoading; + String? get error => _error; + bool get hasFetched => _hasFetched; + + Future fetchTags({ + required String accessToken, + bool forceRefresh = false, + }) async { + if (_isLoading || (_hasFetched && !forceRefresh)) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final result = await _tagsService.getTags(accessToken: accessToken); + + if (result['success'] == true) { + _tags = (result['tags'] as List? ?? const []).cast(); + _hasFetched = true; + _log.info('TagsProvider', 'Fetched ${_tags.length} tags'); + } else { + _error = result['error'] as String?; + _log.error('TagsProvider', 'Failed to fetch tags: $_error'); + } + } catch (e) { + _error = 'Failed to load tags'; + _log.error('TagsProvider', 'Exception fetching tags: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void clear() { + _tags = []; + _hasFetched = false; + _error = null; + notifyListeners(); + } +} diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 634f1f520..07e04c68b 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -32,10 +32,14 @@ class TransactionsProvider with ChangeNotifier { bool get isLoading => _isLoading; String? get error => _error; - bool get hasPendingTransactions => - _transactions.any((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete); - int get pendingCount => - _transactions.where((t) => t.syncStatus == SyncStatus.pending || t.syncStatus == SyncStatus.pendingDelete).length; + bool get hasPendingTransactions => _transactions.any((t) => + t.syncStatus == SyncStatus.pending || + t.syncStatus == SyncStatus.pendingDelete); + int get pendingCount => _transactions + .where((t) => + t.syncStatus == SyncStatus.pending || + t.syncStatus == SyncStatus.pendingDelete) + .length; SyncService get syncService => _syncService; @@ -49,33 +53,31 @@ class TransactionsProvider with ChangeNotifier { void _onConnectivityChanged() { if (_isDisposed) return; - + // Auto-sync when connectivity is restored if (_connectivityService?.isOnline == true && hasPendingTransactions && _lastAccessToken != null && !_isAutoSyncing) { - _log.info('TransactionsProvider', 'Connectivity restored, auto-syncing $pendingCount pending transactions'); + _log.info('TransactionsProvider', + 'Connectivity restored, auto-syncing $pendingCount pending transactions'); _isAutoSyncing = true; // Fire and forget - we don't await to avoid blocking connectivity listener // Use callbacks to handle completion and errors asynchronously - syncTransactions(accessToken: _lastAccessToken!) - .then((_) { - if (!_isDisposed) { - _log.info('TransactionsProvider', 'Auto-sync completed successfully'); - } - }) - .catchError((e) { - if (!_isDisposed) { - _log.error('TransactionsProvider', 'Auto-sync failed: $e'); - } - }) - .whenComplete(() { - if (!_isDisposed) { - _isAutoSyncing = false; - } - }); + syncTransactions(accessToken: _lastAccessToken!).then((_) { + if (!_isDisposed) { + _log.info('TransactionsProvider', 'Auto-sync completed successfully'); + } + }).catchError((e) { + if (!_isDisposed) { + _log.error('TransactionsProvider', 'Auto-sync failed: $e'); + } + }).whenComplete(() { + if (!_isDisposed) { + _isAutoSyncing = false; + } + }); } } @@ -100,29 +102,34 @@ class TransactionsProvider with ChangeNotifier { accountId: accountId, ); - _log.debug('TransactionsProvider', 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)'); + _log.debug('TransactionsProvider', + 'Loaded ${localTransactions.length} transactions from local storage (accountId: $accountId)'); _transactions = localTransactions; notifyListeners(); // If online and force sync, or if local storage is empty, sync from server final isOnline = _connectivityService?.isOnline ?? true; - _log.debug('TransactionsProvider', 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}'); + _log.debug('TransactionsProvider', + 'Online: $isOnline, ForceSync: $forceSync, LocalEmpty: ${localTransactions.isEmpty}'); if (isOnline && (forceSync || localTransactions.isEmpty)) { - _log.debug('TransactionsProvider', 'Syncing from server for accountId: $accountId'); + _log.debug('TransactionsProvider', + 'Syncing from server for accountId: $accountId'); final result = await _syncService.syncFromServer( accessToken: accessToken, accountId: accountId, ); if (result.success) { - _log.info('TransactionsProvider', 'Sync successful, synced ${result.syncedCount} transactions'); + _log.info('TransactionsProvider', + 'Sync successful, synced ${result.syncedCount} transactions'); // Reload from local storage after sync final updatedTransactions = await _offlineStorage.getTransactions( accountId: accountId, ); - _log.debug('TransactionsProvider', 'After sync, loaded ${updatedTransactions.length} transactions from local storage'); + _log.debug('TransactionsProvider', + 'After sync, loaded ${updatedTransactions.length} transactions from local storage'); _transactions = updatedTransactions; _error = null; } else { @@ -151,13 +158,18 @@ class TransactionsProvider with ChangeNotifier { String? notes, String? categoryId, String? categoryName, + String? merchantId, + String? merchantName, + List? tagIds, + List? tagNames, }) async { _lastAccessToken = accessToken; // Store for auto-sync try { final isOnline = _connectivityService?.isOnline ?? false; - _log.info('TransactionsProvider', 'Creating transaction: $name, amount: $amount, online: $isOnline'); + _log.info('TransactionsProvider', + 'Creating transaction: $name, amount: $amount, online: $isOnline'); // ALWAYS save locally first (offline-first strategy) final localTransaction = await _offlineStorage.saveTransaction( @@ -170,20 +182,27 @@ class TransactionsProvider with ChangeNotifier { notes: notes, categoryId: categoryId, categoryName: categoryName, + merchantId: merchantId, + merchantName: merchantName, + tagIds: tagIds ?? const [], + tagNames: tagNames ?? const [], syncStatus: SyncStatus.pending, // Start as pending ); - _log.info('TransactionsProvider', 'Transaction saved locally with ID: ${localTransaction.localId}'); + _log.info('TransactionsProvider', + 'Transaction saved locally with ID: ${localTransaction.localId}'); // Reload transactions to show the new one immediately await fetchTransactions(accessToken: accessToken, accountId: accountId); // If online, try to upload in background if (isOnline) { - _log.info('TransactionsProvider', 'Attempting to upload transaction to server...'); + _log.info('TransactionsProvider', + 'Attempting to upload transaction to server...'); // Don't await - upload in background - _transactionsService.createTransaction( + _transactionsService + .createTransaction( accessToken: accessToken, accountId: accountId, name: name, @@ -193,11 +212,15 @@ class TransactionsProvider with ChangeNotifier { nature: nature, notes: notes, categoryId: categoryId, - ).then((result) async { + merchantId: merchantId, + tagIds: tagIds == null || tagIds.isEmpty ? null : tagIds, + ) + .then((result) async { if (_isDisposed) return; - + if (result['success'] == true) { - _log.info('TransactionsProvider', 'Transaction uploaded successfully'); + _log.info( + 'TransactionsProvider', 'Transaction uploaded successfully'); final serverTransaction = result['transaction'] as Transaction; // Update local transaction with server ID and mark as synced await _offlineStorage.updateTransactionSyncStatus( @@ -206,19 +229,22 @@ class TransactionsProvider with ChangeNotifier { serverId: serverTransaction.id, ); // Reload to update UI - await fetchTransactions(accessToken: accessToken, accountId: accountId); + await fetchTransactions( + accessToken: accessToken, accountId: accountId); } else { - _log.warning('TransactionsProvider', 'Server upload failed: ${result['error']}. Transaction will sync later.'); + _log.warning('TransactionsProvider', + 'Server upload failed: ${result['error']}. Transaction will sync later.'); } }).catchError((e) { if (_isDisposed) return; - + _log.error('TransactionsProvider', 'Exception during upload: $e'); _error = 'Failed to upload transaction. It will sync when online.'; notifyListeners(); }); } else { - _log.info('TransactionsProvider', 'Offline: Transaction will sync when online'); + _log.info('TransactionsProvider', + 'Offline: Transaction will sync when online'); } return true; // Always return true because it's saved locally @@ -230,6 +256,90 @@ class TransactionsProvider with ChangeNotifier { } } + /// Update an existing synced transaction. Edits are online-only because the + /// current offline queue supports create/delete but not pending updates. + Future updateTransaction({ + required String accessToken, + required OfflineTransaction transaction, + String? name, + String? notes, + String? categoryId, + String? merchantId, + List? tagIds, + }) async { + _lastAccessToken = accessToken; + _currentAccountId = transaction.accountId; + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final transactionId = transaction.id; + if (transactionId == null || transactionId.isEmpty) { + _error = 'Only synced transactions can be edited from mobile.'; + return false; + } + + final isOnline = _connectivityService?.isOnline ?? false; + if (!isOnline) { + _error = 'Connect to the internet before editing synced transactions.'; + return false; + } + + final result = await _transactionsService.updateTransaction( + accessToken: accessToken, + transactionId: transactionId, + name: name, + notes: notes, + categoryId: categoryId, + merchantId: merchantId, + tagIds: tagIds, + ); + + if (result['success'] == true) { + var updatedTransaction = result['transaction']; + if (updatedTransaction is! Transaction) { + final refreshed = await _transactionsService.getTransaction( + accessToken: accessToken, + transactionId: transactionId, + ); + updatedTransaction = refreshed['transaction']; + } + + final transactionToCache = updatedTransaction is Transaction + ? updatedTransaction + : transaction.toTransactionWithSubmittedUpdate( + name: name, + notes: notes, + categoryId: categoryId, + merchantId: merchantId, + tagIds: tagIds, + ); + + await _offlineStorage.upsertTransactionFromServer( + transactionToCache, + accountId: transaction.accountId, + ); + final updatedTransactions = await _offlineStorage.getTransactions( + accountId: transaction.accountId, + ); + _transactions = updatedTransactions; + _error = null; + return true; + } + + _error = result['error'] as String? ?? 'Failed to update transaction'; + return false; + } catch (e) { + _log.error('TransactionsProvider', 'Failed to update transaction: $e'); + _error = 'Something went wrong. Please try again.'; + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + /// Delete a transaction Future deleteTransaction({ required String accessToken, @@ -258,7 +368,8 @@ class TransactionsProvider with ChangeNotifier { } } else { // Offline - mark for deletion and sync later - _log.info('TransactionsProvider', 'Offline: Marking transaction for deletion'); + _log.info('TransactionsProvider', + 'Offline: Marking transaction for deletion'); await _offlineStorage.markTransactionForDeletion(transactionId); // Reload from storage to update UI with pending delete status @@ -300,13 +411,15 @@ class TransactionsProvider with ChangeNotifier { notifyListeners(); return true; } else { - _error = result['error'] as String? ?? 'Failed to delete transactions'; + _error = + result['error'] as String? ?? 'Failed to delete transactions'; notifyListeners(); return false; } } else { // Offline - mark all for deletion and sync later - _log.info('TransactionsProvider', 'Offline: Marking ${transactionIds.length} transactions for deletion'); + _log.info('TransactionsProvider', + 'Offline: Marking ${transactionIds.length} transactions for deletion'); for (final id in transactionIds) { await _offlineStorage.markTransactionForDeletion(id); } @@ -320,7 +433,8 @@ class TransactionsProvider with ChangeNotifier { return true; } } catch (e) { - _log.error('TransactionsProvider', 'Failed to delete multiple transactions: $e'); + _log.error( + 'TransactionsProvider', 'Failed to delete multiple transactions: $e'); _error = 'Something went wrong. Please try again.'; notifyListeners(); return false; @@ -332,10 +446,12 @@ class TransactionsProvider with ChangeNotifier { required String localId, required SyncStatus syncStatus, }) async { - _log.info('TransactionsProvider', 'Undoing transaction $localId with status $syncStatus'); + _log.info('TransactionsProvider', + 'Undoing transaction $localId with status $syncStatus'); try { - final success = await _offlineStorage.undoPendingTransaction(localId, syncStatus); + final success = + await _offlineStorage.undoPendingTransaction(localId, syncStatus); if (success) { // Reload from storage to update UI diff --git a/mobile/lib/screens/calendar_screen.dart b/mobile/lib/screens/calendar_screen.dart index 611d54f86..65ccc335c 100644 --- a/mobile/lib/screens/calendar_screen.dart +++ b/mobile/lib/screens/calendar_screen.dart @@ -7,6 +7,7 @@ import '../providers/accounts_provider.dart'; import '../providers/transactions_provider.dart'; import '../providers/auth_provider.dart'; import '../services/log_service.dart'; +import '../utils/amount_parser.dart'; class CalendarScreen extends StatefulWidget { const CalendarScreen({super.key}); @@ -88,10 +89,6 @@ class _CalendarScreenState extends State { final transactions = transactionsProvider.transactions; _log.info('CalendarScreen', 'Loaded ${transactions.length} transactions for account ${_selectedAccount!.name}'); - if (transactions.isNotEmpty) { - _log.debug('CalendarScreen', 'Sample transaction - name: ${transactions.first.name}, amount: ${transactions.first.amount}, nature: ${transactions.first.nature}'); - } - // Store transactions for date filtering _transactions = List.from(transactions); @@ -114,23 +111,7 @@ class _CalendarScreenState extends State { final date = DateTime.parse(transaction.date); final dateKey = DateFormat('yyyy-MM-dd').format(date); - // Parse amount with proper sign handling - String trimmedAmount = transaction.amount.trim(); - trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign - - // Detect if the amount has a negative sign - bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-'); - - // Remove all non-numeric characters except decimal point and minus sign - String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), ''); - - // Parse the numeric value - double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0; - - // Apply the sign from the string - if (hasNegativeSign) { - amount = -amount; - } + var amount = AmountParser.parse(transaction.amount).value; // For asset accounts, flip the sign to match accounting conventions // For liability accounts, also flip the sign @@ -138,12 +119,12 @@ class _CalendarScreenState extends State { amount = -amount; } - _log.debug('CalendarScreen', 'Processing transaction ${transaction.name} - date: $dateKey, raw amount: ${transaction.amount}, parsed: $amount, isAsset: ${_selectedAccount?.isAsset}, isLiability: ${_selectedAccount?.isLiability}'); + _log.debug('CalendarScreen', 'Processing transaction date: $dateKey, parsed amount sign adjusted'); changes[dateKey] = (changes[dateKey] ?? 0.0) + amount; _log.debug('CalendarScreen', 'Date $dateKey now has total: ${changes[dateKey]}'); - } catch (e) { - _log.error('CalendarScreen', 'Failed to parse transaction date: ${transaction.date}, error: $e'); + } catch (_) { + _log.error('CalendarScreen', 'Failed to process transaction for calendar'); } } @@ -248,9 +229,12 @@ class _CalendarScreenState extends State { Widget _buildTransactionTile(Transaction transaction) { // Parse amount to determine if positive or negative - String trimmedAmount = transaction.amount.trim(); - trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); - bool isNegative = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-'); + var isNegative = false; + try { + isNegative = AmountParser.parse(transaction.amount).value < 0; + } on FormatException { + // Keep the dialog renderable if the server returns a malformed amount. + } // For asset accounts, flip the sign interpretation if (_selectedAccount?.isAsset == true || _selectedAccount?.isLiability == true) { diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index 8decc3071..546cea286 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -6,6 +6,7 @@ import '../models/account.dart'; import '../providers/transactions_provider.dart'; import '../providers/accounts_provider.dart'; import '../providers/auth_provider.dart'; +import '../utils/amount_parser.dart'; class RecentTransactionsScreen extends StatefulWidget { const RecentTransactionsScreen({super.key}); @@ -176,35 +177,28 @@ class _RecentTransactionsScreenState extends State { final account = _getAccount(transaction.accountId); final accountName = account?.name ?? 'Unknown Account'; - // Parse amount with proper sign handling (same logic as transactions_list_screen.dart) - String trimmedAmount = transaction.amount.trim(); - trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); // Normalize minus sign - - // Detect if the amount has a negative sign - bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-'); - - // Remove all non-numeric characters except decimal point and minus sign - String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), ''); - - // Parse the numeric value - double amount = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0; - - // Apply the sign from the string - if (hasNegativeSign) { - amount = -amount; + double? amount; + try { + amount = AmountParser.parse(transaction.amount).value; + } on FormatException { + // Keep the list renderable if the server returns a malformed amount. } // For asset accounts and liability accounts, flip the sign to match accounting conventions - if (account?.isAsset == true || account?.isLiability == true) { + if (amount != null && + (account?.isAsset == true || account?.isLiability == true)) { amount = -amount; } // Determine display properties based on final amount - final isPositive = amount >= 0; + final isPositive = amount == null || amount >= 0; Color amountColor; String sign; - if (isPositive) { + if (amount == null) { + amountColor = Colors.grey; + sign = ''; + } else if (isPositive) { amountColor = Colors.green.shade700; sign = '+'; } else { @@ -225,13 +219,19 @@ class _RecentTransactionsScreenState extends State { leading: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: isPositive - ? Colors.green.withValues(alpha: 0.1) - : Colors.red.withValues(alpha: 0.1), + color: amount == null + ? Colors.grey.withValues(alpha: 0.1) + : isPositive + ? Colors.green.withValues(alpha: 0.1) + : Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( - isPositive ? Icons.arrow_upward : Icons.arrow_downward, + amount == null + ? Icons.help_outline + : isPositive + ? Icons.arrow_upward + : Icons.arrow_downward, color: amountColor, ), ), @@ -274,7 +274,9 @@ class _RecentTransactionsScreenState extends State { ], ), trailing: Text( - '$sign${transaction.currency} ${_formatAmount(amount.abs())}', + amount == null + ? transaction.amount + : '$sign${transaction.currency} ${_formatAmount(amount.abs())}', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 676926153..137d8a00b 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../providers/auth_provider.dart'; import '../providers/categories_provider.dart'; +import '../providers/merchants_provider.dart'; +import '../providers/tags_provider.dart'; import '../providers/theme_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; @@ -146,6 +148,8 @@ class _SettingsScreenState extends State { await offlineStorage.clearAllData(); if (context.mounted) { Provider.of(context, listen: false).clear(); + Provider.of(context, listen: false).clear(); + Provider.of(context, listen: false).clear(); } log.info('Settings', 'Local data cleared successfully'); @@ -230,6 +234,8 @@ class _SettingsScreenState extends State { await OfflineStorageService().clearAllData(); if (context.mounted) { Provider.of(context, listen: false).clear(); + Provider.of(context, listen: false).clear(); + Provider.of(context, listen: false).clear(); } if (!context.mounted) return; diff --git a/mobile/lib/screens/transaction_edit_screen.dart b/mobile/lib/screens/transaction_edit_screen.dart new file mode 100644 index 000000000..b7ccf8954 --- /dev/null +++ b/mobile/lib/screens/transaction_edit_screen.dart @@ -0,0 +1,419 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/category.dart' as models; +import '../models/merchant.dart'; +import '../models/offline_transaction.dart'; +import '../models/transaction_tag.dart'; +import '../providers/auth_provider.dart'; +import '../providers/categories_provider.dart'; +import '../providers/merchants_provider.dart'; +import '../providers/tags_provider.dart'; +import '../providers/transactions_provider.dart'; + +class TransactionEditScreen extends StatefulWidget { + final OfflineTransaction transaction; + + const TransactionEditScreen({super.key, required this.transaction}); + + @override + State createState() => _TransactionEditScreenState(); +} + +class _TransactionEditScreenState extends State { + static const _maxNameLength = 255; + static const _maxNotesLength = 2000; + + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _notesController; + String? _selectedCategoryId; + String? _selectedMerchantId; + late Set _selectedTagIds; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.transaction.name); + _notesController = TextEditingController( + text: widget.transaction.notes ?? '', + ); + _selectedCategoryId = widget.transaction.categoryId; + _selectedMerchantId = widget.transaction.merchantId; + _selectedTagIds = widget.transaction.tagIds.toSet(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadMetadata()); + } + + @override + void dispose() { + _nameController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _loadMetadata() async { + final authProvider = Provider.of(context, listen: false); + final categoriesProvider = Provider.of( + context, + listen: false, + ); + final merchantsProvider = Provider.of( + context, + listen: false, + ); + final tagsProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken == null || !mounted) return; + + try { + await Future.wait([ + categoriesProvider.fetchCategories(accessToken: accessToken), + merchantsProvider.fetchMerchants(accessToken: accessToken), + tagsProvider.fetchTags(accessToken: accessToken), + ]); + } catch (_) { + // Providers expose their own error state; avoid an uncaught async error. + } + } + + Future _save() async { + if (!_formKey.currentState!.validate() || widget.transaction.id == null) { + return; + } + + setState(() { + _isSaving = true; + }); + + final authProvider = Provider.of(context, listen: false); + final transactionsProvider = Provider.of( + context, + listen: false, + ); + final accessToken = await authProvider.getValidAccessToken(); + + if (accessToken == null) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Session expired. Please login again.'), + backgroundColor: Colors.red, + ), + ); + setState(() { + _isSaving = false; + }); + return; + } + + // Empty notes intentionally clear the server-side note. + final notesText = _notesController.text.trim(); + + final success = await transactionsProvider.updateTransaction( + accessToken: accessToken, + transaction: widget.transaction, + name: _nameController.text.trim(), + notes: notesText, + categoryId: _selectedCategoryId, + merchantId: _selectedMerchantId, + tagIds: _selectedTagIds.toList(), + ); + + if (!mounted) return; + + setState(() { + _isSaving = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success + ? 'Transaction updated' + : transactionsProvider.error ?? 'Failed to update transaction', + ), + backgroundColor: success ? Colors.green : Colors.red, + ), + ); + + if (success) { + Navigator.pop(context, true); + } + } + + String? _validateName(String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + + if (value.trim().length > _maxNameLength) { + return 'Name must be $_maxNameLength characters or fewer'; + } + + if (_containsControlCharacter(value)) { + return 'Name contains unsupported characters'; + } + + return null; + } + + String? _validateNotes(String? value) { + if (value == null || value.trim().isEmpty) { + return null; + } + + if (value.trim().length > _maxNotesLength) { + return 'Notes must be $_maxNotesLength characters or fewer'; + } + + if (_containsControlCharacter(value, allowWhitespace: true)) { + return 'Notes contain unsupported characters'; + } + + return null; + } + + bool _containsControlCharacter( + String value, { + bool allowWhitespace = false, + }) { + for (final codeUnit in value.codeUnits) { + if (codeUnit == 127) return true; + if (codeUnit < 32) { + final allowedWhitespace = allowWhitespace && + (codeUnit == 9 || codeUnit == 10 || codeUnit == 13); + if (!allowedWhitespace) return true; + } + } + + return false; + } + + List> _categoryItems( + List categories, + ) { + final items = >[]; + if (_selectedCategoryId == null) { + items.add( + const DropdownMenuItem( + value: null, + child: Text('No category'), + ), + ); + } + + final hasCurrent = _selectedCategoryId == null || + categories.any((category) => category.id == _selectedCategoryId); + if (!hasCurrent) { + items.add( + DropdownMenuItem( + value: _selectedCategoryId, + child: Text(widget.transaction.categoryName ?? 'Current category'), + ), + ); + } + + items.addAll( + categories.map((category) { + return DropdownMenuItem( + value: category.id, + child: Text(category.displayName), + ); + }), + ); + + return items; + } + + List> _merchantItems(List merchants) { + final items = >[]; + if (_selectedMerchantId == null) { + items.add( + const DropdownMenuItem( + value: null, + child: Text('No merchant'), + ), + ); + } + + final hasCurrent = _selectedMerchantId == null || + merchants.any((merchant) => merchant.id == _selectedMerchantId); + if (!hasCurrent) { + items.add( + DropdownMenuItem( + value: _selectedMerchantId, + child: Text(widget.transaction.merchantName ?? 'Current merchant'), + ), + ); + } + + items.addAll( + merchants.map((merchant) { + return DropdownMenuItem( + value: merchant.id, + child: Text(merchant.name), + ); + }), + ); + + return items; + } + + Widget _buildTags(List tags, {required bool enabled}) { + if (tags.isEmpty && _selectedTagIds.isEmpty) { + return const Text('No tags available'); + } + + final tagById = {for (final tag in tags) tag.id: tag}; + final combinedTags = [...tags]; + for (final selectedId in _selectedTagIds) { + if (!tagById.containsKey(selectedId)) { + final nameIndex = widget.transaction.tagIds.indexOf(selectedId); + final fallbackName = + nameIndex >= 0 && nameIndex < widget.transaction.tagNames.length + ? widget.transaction.tagNames[nameIndex] + : ''; + combinedTags.add( + TransactionTag( + id: selectedId, + name: fallbackName.isNotEmpty ? fallbackName : 'Unknown tag', + ), + ); + } + } + + return Wrap( + spacing: 8, + runSpacing: 8, + children: combinedTags.map((tag) { + final selected = _selectedTagIds.contains(tag.id); + return FilterChip( + label: Text(tag.name), + selected: selected, + onSelected: enabled + ? (value) { + setState(() { + if (value) { + _selectedTagIds.add(tag.id); + } else { + _selectedTagIds.remove(tag.id); + } + }); + } + : null, + ); + }).toList(), + ); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final canEdit = widget.transaction.id != null && + widget.transaction.syncStatus == SyncStatus.synced; + + return Scaffold( + appBar: AppBar(title: const Text('Edit Transaction')), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (!canEdit) ...[ + Card( + color: colorScheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Only synced transactions can be edited from mobile.', + style: TextStyle(color: colorScheme.onErrorContainer), + ), + ), + ), + const SizedBox(height: 16), + ], + TextFormField( + controller: _nameController, + enabled: canEdit && !_isSaving, + validator: _validateName, + maxLength: _maxNameLength, + decoration: const InputDecoration( + labelText: 'Name', + prefixIcon: Icon(Icons.label), + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _notesController, + enabled: canEdit && !_isSaving, + validator: _validateNotes, + maxLength: _maxNotesLength, + minLines: 2, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Notes', + prefixIcon: Icon(Icons.notes), + ), + ), + const SizedBox(height: 16), + Consumer( + builder: (context, categoriesProvider, _) { + return DropdownButtonFormField( + value: _selectedCategoryId, + decoration: const InputDecoration( + labelText: 'Category', + prefixIcon: Icon(Icons.category), + helperText: 'Choose a replacement category', + ), + isExpanded: true, + items: _categoryItems(categoriesProvider.categories), + onChanged: canEdit && !_isSaving + ? (value) => setState(() => _selectedCategoryId = value) + : null, + ); + }, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, merchantsProvider, _) { + return DropdownButtonFormField( + value: _selectedMerchantId, + decoration: const InputDecoration( + labelText: 'Merchant', + prefixIcon: Icon(Icons.storefront), + helperText: 'Choose a replacement merchant', + ), + isExpanded: true, + items: _merchantItems(merchantsProvider.merchants), + onChanged: canEdit && !_isSaving + ? (value) => setState(() => _selectedMerchantId = value) + : null, + ); + }, + ), + const SizedBox(height: 24), + Text('Tags', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Consumer( + builder: (context, tagsProvider, _) => + _buildTags(tagsProvider.tags, enabled: canEdit && !_isSaving), + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: canEdit && !_isSaving ? _save : null, + icon: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(_isSaving ? 'Saving...' : 'Save Changes'), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index d5513615b..5198b320c 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -8,6 +8,7 @@ import '../providers/categories_provider.dart'; import '../providers/transactions_provider.dart'; import '../services/log_service.dart'; import '../services/connectivity_service.dart'; +import '../utils/amount_parser.dart'; class TransactionFormScreen extends StatefulWidget { final Account account; @@ -66,8 +67,10 @@ class _TransactionFormScreenState extends State { return 'Please enter an amount'; } - final amount = double.tryParse(value.trim()); - if (amount == null) { + final double amount; + try { + amount = AmountParser.parse(value, locale: _currentLocaleName()).value; + } on FormatException { return 'Please enter a valid number'; } @@ -78,6 +81,11 @@ class _TransactionFormScreenState extends State { return null; } + String _currentLocaleName() { + return Localizations.maybeLocaleOf(context)?.toString() ?? + Intl.getCurrentLocale(); + } + Future _selectDate() async { final DateTime? picked = await showDatePicker( context: context, @@ -126,6 +134,10 @@ class _TransactionFormScreenState extends State { // Convert date format from yyyy/MM/dd to yyyy-MM-dd final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text); final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate); + final canonicalAmount = AmountParser.canonicalize( + _amountController.text, + locale: _currentLocaleName(), + ); _log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)'); @@ -135,7 +147,7 @@ class _TransactionFormScreenState extends State { accountId: widget.account.id, name: _nameController.text.trim(), date: apiDate, - amount: _amountController.text.trim(), + amount: canonicalAmount, currency: widget.account.currency, nature: _nature, notes: 'This transaction via mobile app.', diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 0aa6ab131..9369fe35c 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -6,10 +6,12 @@ import '../models/offline_transaction.dart'; import '../providers/auth_provider.dart'; import '../providers/categories_provider.dart'; import '../providers/transactions_provider.dart'; +import '../screens/transaction_edit_screen.dart'; import '../screens/transaction_form_screen.dart'; import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; +import '../utils/amount_parser.dart'; class TransactionsListScreen extends StatefulWidget { final Account account; @@ -39,52 +41,32 @@ class _TransactionsListScreenState extends State { // Amount is a currency-formatted string returned by the API (e.g. may include // currency symbol, grouping separators, locale-dependent decimal separator, // and a sign either before or after the symbol) - Map _getAmountDisplayInfo(String amount, bool isAsset) { + Map _getAmountDisplayInfo( + String amount, + bool isAsset, + bool isLiability, + ) { try { - // Trim whitespace - String trimmedAmount = amount.trim(); + final parsed = AmountParser.parse(amount); + var numericValue = parsed.value; - // Normalize common minus characters (U+002D HYPHEN-MINUS, U+2212 MINUS SIGN) - trimmedAmount = trimmedAmount.replaceAll('\u2212', '-'); - - // Detect if the amount has a negative sign (leading or trailing) - bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-'); - - // Remove all non-numeric characters except decimal point and minus sign - String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), ''); - - // Parse the numeric value - double numericValue = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0; - - // Apply the sign from the string - if (hasNegativeSign) { - numericValue = -numericValue; - } - - // For asset accounts, flip the sign to match accounting conventions - if (isAsset) { + // For asset and liability accounts, flip the sign to match accounting conventions + if (isAsset || isLiability) { numericValue = -numericValue; } // Determine if the final value is positive bool isPositive = numericValue >= 0; - // Get the display amount by removing the sign and currency symbols - String displayAmount = trimmedAmount - .replaceAll('-', '') - .replaceAll('\u2212', '') - .trim(); - return { 'isPositive': isPositive, - 'displayAmount': displayAmount, + 'displayAmount': parsed.displayText, 'color': isPositive ? Colors.green : Colors.red, 'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward, 'prefix': isPositive ? '' : '-', }; - } catch (e) { - // Fallback if parsing fails - log and return neutral state - LogService.instance.error('TransactionsListScreen', 'Failed to parse amount "$amount": $e'); + } on FormatException { + LogService.instance.error('TransactionsListScreen', 'Failed to parse transaction amount'); return { 'isPositive': true, 'displayAmount': amount, @@ -269,6 +251,19 @@ class _TransactionsListScreenState extends State { } } + Future _editTransaction(OfflineTransaction transaction) async { + final updated = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TransactionEditScreen(transaction: transaction), + ), + ); + + if (updated == true && mounted) { + await _loadTransactions(); + } + } + Future _confirmAndDeleteTransaction(Transaction transaction) async { if (transaction.id == null) return false; @@ -496,6 +491,7 @@ class _TransactionsListScreenState extends State { final displayInfo = _getAmountDisplayInfo( transaction.amount, widget.account.isAsset, + widget.account.isLiability, ); return Dismissible( @@ -597,6 +593,30 @@ class _TransactionsListScreenState extends State { color: colorScheme.onSurfaceVariant, ), ), + if (transaction.merchantName != null || + transaction.tagNames.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (transaction.merchantName != null) + Chip( + label: Text(transaction.merchantName!), + visualDensity: VisualDensity.compact, + ), + ...transaction.tagNames + .where((name) => name.isNotEmpty) + .map( + (name) => Chip( + label: Text(name), + visualDensity: + VisualDensity.compact, + ), + ), + ], + ), + ], ], ), ), @@ -614,12 +634,30 @@ class _TransactionsListScreenState extends State { compact: true, ), ), - Text( - '${displayInfo['prefix']}${displayInfo['displayAmount']}', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: displayInfo['color'] as Color, - ), + if (!_isSelectionMode && + transaction.syncStatus == + SyncStatus.synced) + SizedBox( + width: 36, + height: 36, + child: IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Edit transaction', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + onPressed: () => + _editTransaction(transaction), + ), + ), + Flexible( + child: Text( + '${displayInfo['prefix']}${displayInfo['displayAmount']}', + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: displayInfo['color'] as Color, + ), + ), ), ], ), diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart index fe893b85f..d0145edba 100644 --- a/mobile/lib/services/database_helper.dart +++ b/mobile/lib/services/database_helper.dart @@ -38,12 +38,13 @@ class DatabaseHelper { throw StateError('sqflite database is not available on web.'); } if (_database != null) return _database!; - + try { _database = await _initDB('sure_offline.db'); return _database!; } catch (e, stackTrace) { - _log.error('DatabaseHelper', 'Error initializing local database sure_offline.db: $e'); + _log.error('DatabaseHelper', + 'Error initializing local database sure_offline.db: $e'); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -63,12 +64,13 @@ class DatabaseHelper { return await openDatabase( path, - version: 2, + version: 3, onCreate: _createDB, onUpgrade: _upgradeDB, ); } catch (e, stackTrace) { - _log.error('DatabaseHelper', 'Error opening database file "$filePath": $e'); + _log.error( + 'DatabaseHelper', 'Error opening database file "$filePath": $e'); FlutterError.reportError( FlutterErrorDetails( exception: e, @@ -97,6 +99,10 @@ class DatabaseHelper { notes TEXT, category_id TEXT, category_name TEXT, + merchant_id TEXT, + merchant_name TEXT, + tag_ids TEXT DEFAULT '[]', + tag_names TEXT DEFAULT '[]', sync_status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -156,10 +162,32 @@ class DatabaseHelper { final columns = await db.rawQuery('PRAGMA table_info(transactions)'); final columnNames = columns.map((c) => c['name'] as String).toSet(); if (!columnNames.contains('category_id')) { - await db.execute('ALTER TABLE transactions ADD COLUMN category_id TEXT'); + await db + .execute('ALTER TABLE transactions ADD COLUMN category_id TEXT'); } if (!columnNames.contains('category_name')) { - await db.execute('ALTER TABLE transactions ADD COLUMN category_name TEXT'); + await db + .execute('ALTER TABLE transactions ADD COLUMN category_name TEXT'); + } + } + if (oldVersion < 3) { + final columns = await db.rawQuery('PRAGMA table_info(transactions)'); + final columnNames = columns.map((c) => c['name'] as String).toSet(); + if (!columnNames.contains('merchant_id')) { + await db + .execute('ALTER TABLE transactions ADD COLUMN merchant_id TEXT'); + } + if (!columnNames.contains('merchant_name')) { + await db + .execute('ALTER TABLE transactions ADD COLUMN merchant_name TEXT'); + } + if (!columnNames.contains('tag_ids')) { + await db.execute( + "ALTER TABLE transactions ADD COLUMN tag_ids TEXT DEFAULT '[]'"); + } + if (!columnNames.contains('tag_names')) { + await db.execute( + "ALTER TABLE transactions ADD COLUMN tag_names TEXT DEFAULT '[]'"); } } } @@ -173,7 +201,8 @@ class DatabaseHelper { return localId; } final db = await database; - _log.debug('DatabaseHelper', 'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}'); + _log.debug('DatabaseHelper', + 'Inserting transaction: local_id=${transaction['local_id']}, account_id="${transaction['account_id']}", server_id=${transaction['server_id']}'); await db.insert( 'transactions', transaction, @@ -183,7 +212,8 @@ class DatabaseHelper { return transaction['local_id'] as String; } - Future>> getTransactions({String? accountId}) async { + Future>> getTransactions( + {String? accountId}) async { if (_useInMemoryStore) { _ensureWebStoreReady(); final results = _memoryTransactions.values @@ -194,16 +224,19 @@ class DatabaseHelper { .map((transaction) => Map.from(transaction)) .toList(); results.sort((a, b) { - final dateCompare = _compareDesc(a['date'] as String?, b['date'] as String?); + final dateCompare = + _compareDesc(a['date'] as String?, b['date'] as String?); if (dateCompare != 0) return dateCompare; - return _compareDesc(a['created_at'] as String?, b['created_at'] as String?); + return _compareDesc( + a['created_at'] as String?, b['created_at'] as String?); }); return results; } final db = await database; if (accountId != null) { - _log.debug('DatabaseHelper', 'Querying transactions WHERE account_id = "$accountId"'); + _log.debug('DatabaseHelper', + 'Querying transactions WHERE account_id = "$accountId"'); final results = await db.query( 'transactions', where: 'account_id = ?', @@ -227,7 +260,9 @@ class DatabaseHelper { if (_useInMemoryStore) { _ensureWebStoreReady(); final transaction = _memoryTransactions[localId]; - return transaction != null ? Map.from(transaction) : null; + return transaction != null + ? Map.from(transaction) + : null; } final db = await database; final results = await db.query( @@ -240,7 +275,8 @@ class DatabaseHelper { return results.isNotEmpty ? results.first : null; } - Future?> getTransactionByServerId(String serverId) async { + Future?> getTransactionByServerId( + String serverId) async { if (_useInMemoryStore) { _ensureWebStoreReady(); for (final transaction in _memoryTransactions.values) { @@ -269,7 +305,8 @@ class DatabaseHelper { .map((transaction) => Map.from(transaction)) .toList(); results.sort( - (a, b) => _compareAsc(a['created_at'] as String?, b['created_at'] as String?), + (a, b) => + _compareAsc(a['created_at'] as String?, b['created_at'] as String?), ); return results; } @@ -286,11 +323,13 @@ class DatabaseHelper { if (_useInMemoryStore) { _ensureWebStoreReady(); final results = _memoryTransactions.values - .where((transaction) => transaction['sync_status'] == 'pending_delete') + .where( + (transaction) => transaction['sync_status'] == 'pending_delete') .map((transaction) => Map.from(transaction)) .toList(); results.sort( - (a, b) => _compareAsc(a['updated_at'] as String?, b['updated_at'] as String?), + (a, b) => + _compareAsc(a['updated_at'] as String?, b['updated_at'] as String?), ); return results; } @@ -303,7 +342,8 @@ class DatabaseHelper { ); } - Future updateTransaction(String localId, Map transaction) async { + Future updateTransaction( + String localId, Map transaction) async { if (_useInMemoryStore) { _ensureWebStoreReady(); if (!_memoryTransactions.containsKey(localId)) { @@ -381,7 +421,8 @@ class DatabaseHelper { return; } final db = await database; - _log.debug('DatabaseHelper', 'Clearing only synced transactions, keeping pending/failed'); + _log.debug('DatabaseHelper', + 'Clearing only synced transactions, keeping pending/failed'); await db.delete( 'transactions', where: 'sync_status = ?', diff --git a/mobile/lib/services/merchants_service.dart b/mobile/lib/services/merchants_service.dart new file mode 100644 index 000000000..3dbf7d7d1 --- /dev/null +++ b/mobile/lib/services/merchants_service.dart @@ -0,0 +1,18 @@ +import '../models/merchant.dart'; +import 'response_list_parser.dart'; + +class MerchantsService { + Future> getMerchants({ + required String accessToken, + }) async { + return fetchApiList( + accessToken: accessToken, + path: '/api/v1/merchants', + key: 'merchants', + resultKey: 'merchants', + fromJson: Merchant.fromJson, + isValid: (merchant) => merchant.id.isNotEmpty, + failureMessage: 'Failed to fetch merchants', + ); + } +} diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index 06b0a687b..520de7763 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -21,12 +21,18 @@ class OfflineStorageService { String? notes, String? categoryId, String? categoryName, + String? merchantId, + String? merchantName, + List tagIds = const [], + List tagNames = const [], String? serverId, SyncStatus syncStatus = SyncStatus.pending, }) async { - _log.info('OfflineStorage', 'saveTransaction called: name=$name, amount=$amount, accountId=$accountId, syncStatus=$syncStatus'); - final localId = _uuid.v4(); + + _log.info('OfflineStorage', + 'saveTransaction called: localId=$localId, accountId=$accountId, syncStatus=$syncStatus'); + final transaction = OfflineTransaction( id: serverId, localId: localId, @@ -39,12 +45,17 @@ class OfflineStorageService { notes: notes, categoryId: categoryId, categoryName: categoryName, + merchantId: merchantId, + merchantName: merchantName, + tagIds: tagIds, + tagNames: tagNames, syncStatus: syncStatus, ); try { await _dbHelper.insertTransaction(transaction.toDatabaseMap()); - _log.info('OfflineStorage', 'Transaction saved successfully with localId: $localId'); + _log.info('OfflineStorage', + 'Transaction saved successfully with localId: $localId'); return transaction; } catch (e) { _log.error('OfflineStorage', 'Failed to save transaction: $e'); @@ -53,22 +64,27 @@ class OfflineStorageService { } Future> getTransactions({String? accountId}) async { - _log.debug('OfflineStorage', 'getTransactions called with accountId: $accountId'); - final transactionMaps = await _dbHelper.getTransactions(accountId: accountId); - _log.debug('OfflineStorage', 'Retrieved ${transactionMaps.length} transaction maps from database'); + _log.debug( + 'OfflineStorage', 'getTransactions called with accountId: $accountId'); + final transactionMaps = + await _dbHelper.getTransactions(accountId: accountId); + _log.debug('OfflineStorage', + 'Retrieved ${transactionMaps.length} transaction maps from database'); if (transactionMaps.isNotEmpty && accountId != null) { _log.debug('OfflineStorage', 'Sample transaction account_ids:'); for (int i = 0; i < transactionMaps.take(3).length; i++) { final map = transactionMaps[i]; - _log.debug('OfflineStorage', ' - Transaction ${map['server_id']}: account_id="${map['account_id']}"'); + _log.debug('OfflineStorage', + ' - Transaction ${map['server_id']}: account_id="${map['account_id']}"'); } } final transactions = transactionMaps .map((map) => OfflineTransaction.fromDatabaseMap(map)) .toList(); - _log.debug('OfflineStorage', 'Returning ${transactions.length} transactions'); + _log.debug( + 'OfflineStorage', 'Returning ${transactions.length} transactions'); return transactions; } @@ -123,12 +139,14 @@ class OfflineStorageService { /// Mark a transaction for pending deletion (offline delete) Future markTransactionForDeletion(String serverId) async { - _log.info('OfflineStorage', 'Marking transaction $serverId for pending deletion'); + _log.info( + 'OfflineStorage', 'Marking transaction $serverId for pending deletion'); // Find the transaction by server ID final existing = await getTransactionByServerId(serverId); if (existing == null) { - _log.warning('OfflineStorage', 'Transaction $serverId not found, cannot mark for deletion'); + _log.warning('OfflineStorage', + 'Transaction $serverId not found, cannot mark for deletion'); return; } @@ -138,28 +156,35 @@ class OfflineStorageService { updatedAt: DateTime.now(), ); - await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap()); - _log.info('OfflineStorage', 'Transaction ${existing.localId} marked as pending_delete'); + await _dbHelper.updateTransaction( + existing.localId, updated.toDatabaseMap()); + _log.info('OfflineStorage', + 'Transaction ${existing.localId} marked as pending_delete'); } /// Undo a pending transaction operation (either pending create or pending delete) - Future undoPendingTransaction(String localId, SyncStatus currentStatus) async { - _log.info('OfflineStorage', 'Undoing pending transaction $localId with status $currentStatus'); + Future undoPendingTransaction( + String localId, SyncStatus currentStatus) async { + _log.info('OfflineStorage', + 'Undoing pending transaction $localId with status $currentStatus'); final existing = await getTransactionByLocalId(localId); if (existing == null) { - _log.warning('OfflineStorage', 'Transaction $localId not found, cannot undo'); + _log.warning( + 'OfflineStorage', 'Transaction $localId not found, cannot undo'); return false; } if (currentStatus == SyncStatus.pending) { // For pending creates: delete the transaction completely - _log.info('OfflineStorage', 'Deleting pending create transaction $localId'); + _log.info( + 'OfflineStorage', 'Deleting pending create transaction $localId'); await deleteTransaction(localId); return true; } else if (currentStatus == SyncStatus.pendingDelete) { // For pending deletes: restore to synced status - _log.info('OfflineStorage', 'Restoring pending delete transaction $localId to synced'); + _log.info('OfflineStorage', + 'Restoring pending delete transaction $localId to synced'); final updated = existing.copyWith( syncStatus: SyncStatus.synced, updatedAt: DateTime.now(), @@ -168,21 +193,26 @@ class OfflineStorageService { return true; } - _log.warning('OfflineStorage', 'Cannot undo transaction with status $currentStatus'); + _log.warning( + 'OfflineStorage', 'Cannot undo transaction with status $currentStatus'); return false; } - Future syncTransactionsFromServer(List serverTransactions) async { - _log.info('OfflineStorage', 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); + Future syncTransactionsFromServer( + List serverTransactions) async { + _log.info('OfflineStorage', + 'syncTransactionsFromServer called with ${serverTransactions.length} transactions from server'); // Log first transaction's accountId for debugging if (serverTransactions.isNotEmpty) { final firstTx = serverTransactions.first; - _log.info('OfflineStorage', 'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"'); + _log.info('OfflineStorage', + 'First transaction: id=${firstTx.id}, accountId="${firstTx.accountId}", name="${firstTx.name}"'); } // Use upsert logic instead of clear + insert to preserve recently uploaded transactions - _log.info('OfflineStorage', 'Upserting all transactions from server (preserving pending/failed)'); + _log.info('OfflineStorage', + 'Upserting all transactions from server (preserving pending/failed)'); int upsertedCount = 0; int emptyAccountIdCount = 0; @@ -196,9 +226,11 @@ class OfflineStorageService { } } - _log.info('OfflineStorage', 'Upserted $upsertedCount transactions from server'); + _log.info( + 'OfflineStorage', 'Upserted $upsertedCount transactions from server'); if (emptyAccountIdCount > 0) { - _log.error('OfflineStorage', 'WARNING: $emptyAccountIdCount transactions had EMPTY accountId!'); + _log.error('OfflineStorage', + 'WARNING: $emptyAccountIdCount transactions had EMPTY accountId!'); } } @@ -212,13 +244,15 @@ class OfflineStorageService { } // If accountId is provided and transaction.accountId is empty, use the provided one - final effectiveAccountId = transaction.accountId.isEmpty && accountId != null - ? accountId - : transaction.accountId; + final effectiveAccountId = + transaction.accountId.isEmpty && accountId != null + ? accountId + : transaction.accountId; // Log if transaction has empty accountId if (transaction.accountId.isEmpty) { - _log.warning('OfflineStorage', 'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId'); + _log.warning('OfflineStorage', + 'Transaction ${transaction.id} has empty accountId from server! Provided accountId: $accountId, effective: $effectiveAccountId'); } // Check if we already have this transaction @@ -226,31 +260,25 @@ class OfflineStorageService { if (existing != null) { // Update existing transaction, preserving its accountId if effectiveAccountId is empty - final finalAccountId = effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId; + final finalAccountId = + effectiveAccountId.isEmpty ? existing.accountId : effectiveAccountId; if (finalAccountId.isEmpty) { - _log.error('OfflineStorage', 'CRITICAL: Updating transaction ${transaction.id} with EMPTY accountId!'); + _log.error('OfflineStorage', + 'CRITICAL: Updating transaction ${transaction.id} with EMPTY accountId!'); } - final updated = OfflineTransaction( - id: transaction.id, - localId: existing.localId, + final updated = existing.mergeServerTransaction( + transaction, accountId: finalAccountId, - name: transaction.name, - date: transaction.date, - amount: transaction.amount, - currency: transaction.currency, - nature: transaction.nature, - notes: transaction.notes, - categoryId: transaction.categoryId ?? existing.categoryId, - categoryName: transaction.categoryName ?? existing.categoryName, - syncStatus: SyncStatus.synced, ); - await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap()); + await _dbHelper.updateTransaction( + existing.localId, updated.toDatabaseMap()); } else { // Insert new transaction if (effectiveAccountId.isEmpty) { - _log.error('OfflineStorage', 'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!'); + _log.error('OfflineStorage', + 'CRITICAL: Inserting transaction ${transaction.id} with EMPTY accountId!'); } final offlineTransaction = OfflineTransaction( @@ -265,6 +293,10 @@ class OfflineStorageService { notes: transaction.notes, categoryId: transaction.categoryId, categoryName: transaction.categoryName, + merchantId: transaction.merchantId, + merchantName: transaction.merchantName, + tagIds: transaction.tagIds, + tagNames: transaction.tagNames, syncStatus: SyncStatus.synced, ); await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap()); @@ -291,15 +323,17 @@ class OfflineStorageService { } Future saveAccounts(List accounts) async { - final accountMaps = accounts.map((account) => { - 'id': account.id, - 'name': account.name, - 'balance': account.balance, - 'currency': account.currency, - 'classification': account.classification, - 'account_type': account.accountType, - 'synced_at': DateTime.now().toIso8601String(), - }).toList(); + final accountMaps = accounts + .map((account) => { + 'id': account.id, + 'name': account.name, + 'balance': account.balance, + 'currency': account.currency, + 'classification': account.classification, + 'account_type': account.accountType, + 'synced_at': DateTime.now().toIso8601String(), + }) + .toList(); await _dbHelper.insertAccounts(accountMaps); } diff --git a/mobile/lib/services/response_list_parser.dart b/mobile/lib/services/response_list_parser.dart new file mode 100644 index 000000000..45763fae2 --- /dev/null +++ b/mobile/lib/services/response_list_parser.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'api_config.dart'; + +List> extractJsonObjectList( + dynamic responseData, { + String? key, +}) { + final dynamic rawList; + if (responseData is List) { + rawList = responseData; + } else if (responseData is Map && key != null) { + rawList = responseData[key] is List ? responseData[key] : const []; + } else if (responseData is Map) { + final lists = responseData.values.whereType(); + rawList = lists.isEmpty ? const [] : lists.first; + } else { + rawList = const []; + } + + return (rawList as List) + .whereType() + .map((item) => item.cast()) + .toList(); +} + +Future> fetchApiList({ + required String accessToken, + required String path, + required String key, + required String resultKey, + required T Function(Map) fromJson, + required bool Function(T) isValid, + required String failureMessage, +}) async { + final url = Uri.parse('${ApiConfig.baseUrl}$path'); + + try { + final response = await http.get( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + final items = []; + for (final json in extractJsonObjectList(responseData, key: key)) { + try { + final item = fromJson(json); + if (isValid(item)) { + items.add(item); + } + } on FormatException { + // Skip malformed metadata records instead of failing the whole list. + } + } + + return {'success': true, resultKey: items}; + } else if (response.statusCode == 401) { + return {'success': false, 'error': 'unauthorized'}; + } + + return { + 'success': false, + 'error': extractErrorMessage(response.body, fallback: failureMessage), + }; + } catch (e) { + return {'success': false, 'error': 'Network error: ${e.toString()}'}; + } +} + +String extractErrorMessage(String responseBody, {required String fallback}) { + try { + final responseData = jsonDecode(responseBody); + if (responseData is Map) { + final message = responseData['message'] ?? responseData['error']; + if (message != null && message.toString().trim().isNotEmpty) { + return message.toString(); + } + } + } catch (_) { + // Fall through to the static caller-provided fallback. + } + + return fallback; +} diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index a5dab6543..c40ef467e 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -126,6 +126,8 @@ class SyncService with ChangeNotifier { nature: transaction.nature, notes: transaction.notes, categoryId: transaction.categoryId, + merchantId: transaction.merchantId, + tagIds: transaction.tagIds, ); if (result['success'] == true) { diff --git a/mobile/lib/services/tags_service.dart b/mobile/lib/services/tags_service.dart new file mode 100644 index 000000000..da547015a --- /dev/null +++ b/mobile/lib/services/tags_service.dart @@ -0,0 +1,16 @@ +import '../models/transaction_tag.dart'; +import 'response_list_parser.dart'; + +class TagsService { + Future> getTags({required String accessToken}) async { + return fetchApiList( + accessToken: accessToken, + path: '/api/v1/tags', + key: 'tags', + resultKey: 'tags', + fromJson: TransactionTag.fromJson, + isValid: (tag) => tag.id.isNotEmpty, + failureMessage: 'Failed to fetch tags', + ); + } +} diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index 83af27de8..233bde178 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -4,6 +4,11 @@ import '../models/transaction.dart'; import 'api_config.dart'; class TransactionsService { + final http.Client _client; + + TransactionsService({http.Client? client}) + : _client = client ?? http.Client(); + Future> createTransaction({ required String accessToken, required String accountId, @@ -14,6 +19,8 @@ class TransactionsService { required String nature, String? notes, String? categoryId, + String? merchantId, + List? tagIds, }) async { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); @@ -27,18 +34,22 @@ class TransactionsService { 'nature': nature, if (notes != null) 'notes': notes, if (categoryId != null) 'category_id': categoryId, + if (merchantId != null) 'merchant_id': merchantId, + if (tagIds != null) 'tag_ids': tagIds, } }; try { - final response = await http.post( - url, - headers: { - ...ApiConfig.getAuthHeaders(accessToken), - 'Content-Type': 'application/json', - }, - body: jsonEncode(body), - ).timeout(const Duration(seconds: 30)); + final response = await _client + .post( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + body: jsonEncode(body), + ) + .timeout(const Duration(seconds: 30)); if (response.statusCode == 200 || response.statusCode == 201) { final responseData = jsonDecode(response.body); @@ -52,18 +63,13 @@ class TransactionsService { 'error': 'unauthorized', }; } else { - try { - final responseData = jsonDecode(response.body); - return { - 'success': false, - 'error': responseData['error'] ?? 'Failed to create transaction', - }; - } catch (e) { - return { - 'success': false, - 'error': 'Failed to create transaction: ${response.body}', - }; - } + return { + 'success': false, + 'error': errorMessageFromResponseBody( + response.body, + fallback: 'Failed to create transaction', + ), + }; } } catch (e) { return { @@ -97,7 +103,7 @@ class TransactionsService { : baseUri; try { - final response = await http.get( + final response = await _client.get( url, headers: { ...ApiConfig.getAuthHeaders(accessToken), @@ -114,7 +120,8 @@ class TransactionsService { if (responseData is List) { transactionsJson = responseData; - } else if (responseData is Map && responseData.containsKey('transactions')) { + } else if (responseData is Map && + responseData.containsKey('transactions')) { transactionsJson = responseData['transactions']; // Extract pagination metadata if present if (responseData.containsKey('pagination')) { @@ -124,9 +131,8 @@ class TransactionsService { transactionsJson = []; } - final transactions = transactionsJson - .map((json) => Transaction.fromJson(json)) - .toList(); + final transactions = + transactionsJson.map((json) => Transaction.fromJson(json)).toList(); return { 'success': true, @@ -152,14 +158,141 @@ class TransactionsService { } } + Future> getTransaction({ + required String accessToken, + required String transactionId, + }) async { + final url = + Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId'); + + try { + final response = await _client.get( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + return { + 'success': true, + 'transaction': Transaction.fromJson(responseData), + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': errorMessageFromResponseBody( + response.body, + fallback: 'Failed to fetch transaction', + ), + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + + Future> updateTransaction({ + required String accessToken, + required String transactionId, + String? name, + String? date, + String? amount, + String? currency, + String? nature, + String? notes, + String? categoryId, + String? merchantId, + List? tagIds, + }) async { + final url = + Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId'); + + final transaction = { + if (name != null) 'name': name, + if (date != null) 'date': date, + if (amount != null) 'amount': amount, + if (currency != null) 'currency': currency, + if (nature != null) 'nature': nature, + if (notes != null) 'notes': notes, + if (categoryId != null) 'category_id': categoryId, + if (merchantId != null) 'merchant_id': merchantId, + if (tagIds != null) 'tag_ids': tagIds, + }; + + if (transaction.isEmpty) { + return { + 'success': false, + 'error': 'No fields to update', + }; + } + + try { + final response = await _client + .patch( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + body: jsonEncode({'transaction': transaction}), + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200 || response.statusCode == 204) { + if (response.body.trim().isEmpty) { + return { + 'success': true, + 'transaction': null, + }; + } + + final responseData = jsonDecode(response.body); + return { + 'success': true, + 'transaction': Transaction.fromJson(responseData), + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': errorMessageFromResponseBody( + response.body, + fallback: 'Failed to update transaction', + ), + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } + Future> deleteTransaction({ required String accessToken, required String transactionId, }) async { - final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId'); + final url = + Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions/$transactionId'); try { - final response = await http.delete( + final response = await _client.delete( url, headers: { ...ApiConfig.getAuthHeaders(accessToken), @@ -205,9 +338,9 @@ class TransactionsService { try { final results = await Future.wait( transactionIds.map((id) => deleteTransaction( - accessToken: accessToken, - transactionId: id, - )), + accessToken: accessToken, + transactionId: id, + )), ); final allSuccess = results.every((result) => result['success'] == true); @@ -231,4 +364,72 @@ class TransactionsService { }; } } + + static String errorMessageFromResponseBody( + String body, { + required String fallback, + }) { + try { + final responseData = jsonDecode(body); + if (responseData is! Map) return fallback; + + final message = responseData['message'] ?? responseData['error']; + final errors = responseData['errors']; + final formattedErrors = _formatErrors(errors); + + if (message != null && formattedErrors != null) { + return '${message.toString()}: $formattedErrors'; + } + + if (formattedErrors != null) return formattedErrors; + if (message != null) return message.toString(); + } catch (_) { + return fallback; + } + + return fallback; + } + + static String? _formatErrors(dynamic errors) { + if (errors is List) { + final parts = errors + .map((error) => error?.toString().trim() ?? '') + .where((error) => error.isNotEmpty) + .toList(); + return parts.isEmpty ? null : parts.join('; '); + } + + if (errors is Map) { + final parts = []; + for (final entry in errors.entries) { + final field = _humanizeField(entry.key.toString()); + final value = entry.value; + if (value is List) { + for (final message in value) { + final text = message?.toString().trim() ?? ''; + if (text.isNotEmpty) parts.add('$field $text'); + } + } else { + final text = value?.toString().trim() ?? ''; + if (text.isNotEmpty) parts.add('$field $text'); + } + } + return parts.isEmpty ? null : parts.join('; '); + } + + return null; + } + + static String _humanizeField(String field) { + final words = field + .replaceAll('_', ' ') + .split(' ') + .where((word) => word.isNotEmpty) + .toList(); + if (words.isEmpty) return field; + + final first = words.first; + words[0] = first[0].toUpperCase() + first.substring(1); + return words.join(' '); + } } diff --git a/mobile/lib/utils/amount_parser.dart b/mobile/lib/utils/amount_parser.dart new file mode 100644 index 000000000..aa1cf662a --- /dev/null +++ b/mobile/lib/utils/amount_parser.dart @@ -0,0 +1,204 @@ +import 'package:intl/intl.dart'; + +class ParsedAmount { + const ParsedAmount({ + required this.value, + required this.canonicalValue, + required this.displayText, + }); + + final double value; + final String canonicalValue; + final String displayText; +} + +class AmountParser { + static ParsedAmount parse(String input, {String? locale}) { + final normalized = _normalizeMinus(input.trim()); + final negative = _isNegative(normalized); + final numericText = normalized.replaceAll(RegExp(r'[^0-9.,]'), ''); + + if (!RegExp(r'\d').hasMatch(numericText)) { + throw const FormatException('Amount must contain digits'); + } + + final separators = _Separators.forLocale(locale); + final decimalSeparator = _decimalSeparatorFor(numericText, separators); + final canonical = _canonicalize( + numericText, + decimalSeparator: decimalSeparator, + negative: negative, + ); + + return ParsedAmount( + value: double.parse(canonical), + canonicalValue: canonical, + displayText: _displayText(normalized), + ); + } + + static String canonicalize(String input, {String? locale}) { + return parse(input, locale: locale).canonicalValue; + } + + static String _normalizeMinus(String value) { + return value + .replaceAll('\u2212', '-') + .replaceAll('\u2012', '-') + .replaceAll('\u2013', '-') + .replaceAll('\u2014', '-'); + } + + static bool _isNegative(String value) { + final trimmed = value.trim(); + return trimmed.contains('-') || + (trimmed.startsWith('(') && trimmed.endsWith(')')); + } + + static String _displayText(String value) { + var display = value.trim(); + + if (display.startsWith('(') && display.endsWith(')')) { + display = display.substring(1, display.length - 1).trim(); + } + + display = display.replaceAll(RegExp(r'[-+]'), '').trim(); + + return display; + } + + static String _canonicalize( + String numericText, { + required String? decimalSeparator, + required bool negative, + }) { + String integerDigits; + String fractionDigits = ''; + + if (decimalSeparator == null) { + integerDigits = numericText.replaceAll(RegExp(r'\D'), ''); + } else { + final decimalIndex = numericText.lastIndexOf(decimalSeparator); + integerDigits = + numericText.substring(0, decimalIndex).replaceAll(RegExp(r'\D'), ''); + fractionDigits = + numericText.substring(decimalIndex + 1).replaceAll(RegExp(r'\D'), ''); + } + + integerDigits = integerDigits.replaceFirst(RegExp(r'^0+(?=\d)'), ''); + if (integerDigits.isEmpty) { + integerDigits = '0'; + } + + fractionDigits = fractionDigits.replaceFirst(RegExp(r'0+$'), ''); + + final unsigned = fractionDigits.isEmpty + ? integerDigits + : '$integerDigits.$fractionDigits'; + + if (!negative || unsigned == '0') { + return unsigned; + } + + return '-$unsigned'; + } + + static String? _decimalSeparatorFor( + String numericText, + _Separators separators, + ) { + final lastDot = numericText.lastIndexOf('.'); + final lastComma = numericText.lastIndexOf(','); + + if (lastDot == -1 && lastComma == -1) { + return null; + } + + if (lastDot != -1 && lastComma != -1) { + return lastDot > lastComma ? '.' : ','; + } + + final separator = lastDot == -1 ? ',' : '.'; + final parts = numericText.split(separator); + + if (parts.length > 2) { + if (_looksGrouped(parts)) { + return null; + } + + throw const FormatException('Invalid amount format'); + } + + if (separator == separators.decimalSeparator) { + return separator; + } + + final lastGroupLength = parts.last.length; + if (separator == separators.groupSeparator && lastGroupLength == 3) { + return null; + } + + if (_looksGrouped(parts)) { + return null; + } + + return separator; + } + + static bool _looksGrouped(List parts) { + if (parts.length < 2 || parts.first.isEmpty || parts.first.length > 3) { + return false; + } + + return parts.skip(1).every((part) => part.length == 3); + } +} + +class _Separators { + const _Separators({ + required this.decimalSeparator, + required this.groupSeparator, + }); + + final String decimalSeparator; + final String groupSeparator; + + static _Separators forLocale(String? locale) { + final effectiveLocale = locale ?? Intl.getCurrentLocale(); + + try { + final symbols = NumberFormat.decimalPattern(effectiveLocale).symbols; + return _Separators( + decimalSeparator: symbols.DECIMAL_SEP, + groupSeparator: symbols.GROUP_SEP, + ); + } catch (_) { + if (_usesDecimalComma(effectiveLocale)) { + return const _Separators(decimalSeparator: ',', groupSeparator: '.'); + } + + return const _Separators(decimalSeparator: '.', groupSeparator: ','); + } + } + + // Fallback heuristic for common decimal-comma locales when NumberFormat's + // locale database cannot resolve the requested locale. Keep the set limited + // to languages we intentionally support and extend it with tests as needed. + static bool _usesDecimalComma(String locale) { + final language = locale.split(RegExp(r'[-_]')).first.toLowerCase(); + return { + 'ca', + 'de', + 'es', + 'fr', + 'hu', + 'id', + 'it', + 'nl', + 'pl', + 'pt', + 'ro', + 'tr', + }.contains(language); + } +} diff --git a/mobile/test/models/transaction_metadata_test.dart b/mobile/test/models/transaction_metadata_test.dart new file mode 100644 index 000000000..b65727950 --- /dev/null +++ b/mobile/test/models/transaction_metadata_test.dart @@ -0,0 +1,308 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/models/offline_transaction.dart'; +import 'package:sure_mobile/models/transaction.dart'; + +void main() { + group('Transaction metadata', () { + test('parses merchant and tags from API response', () { + final transaction = Transaction.fromJson({ + 'id': 'tx_1', + 'account': {'id': 'acct_1'}, + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'classification': 'expense', + 'notes': 'latte', + 'category': {'id': 'cat_1', 'name': 'Dining'}, + 'merchant': {'id': 'merchant_1', 'name': 'Cafe'}, + 'tags': [ + {'id': 'tag_1', 'name': 'Work'}, + {'id': 'tag_2', 'name': 'Travel'}, + ], + }); + + expect(transaction.merchantId, 'merchant_1'); + expect(transaction.merchantName, 'Cafe'); + expect(transaction.tagIds, ['tag_1', 'tag_2']); + expect(transaction.tagNames, ['Work', 'Travel']); + }); + + test('round-trips merchant and tag metadata through offline maps', () { + final offlineTransaction = OfflineTransaction.fromTransaction( + Transaction( + id: 'tx_1', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: r'$4.50', + currency: 'USD', + nature: 'expense', + merchantId: 'merchant_1', + merchantName: 'Cafe', + tagIds: const ['tag_1', 'tag_2'], + tagNames: const ['Work', 'Travel'], + ), + localId: 'local_1', + ); + + final restored = OfflineTransaction.fromDatabaseMap( + offlineTransaction.toDatabaseMap(), + ); + + expect(restored.merchantId, 'merchant_1'); + expect(restored.merchantName, 'Cafe'); + expect(restored.tagIds, ['tag_1', 'tag_2']); + expect(restored.tagNames, ['Work', 'Travel']); + expect(restored.syncStatus, SyncStatus.synced); + }); + + test('preserves omitted tag state for stored rows without tag columns', () { + final restored = OfflineTransaction.fromDatabaseMap({ + 'server_id': 'tx_1', + 'local_id': 'local_1', + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'notes': null, + 'category_id': null, + 'category_name': null, + 'merchant_id': null, + 'merchant_name': null, + 'sync_status': 'synced', + 'created_at': '2026-06-01T00:00:00.000', + 'updated_at': '2026-06-01T00:00:00.000', + }); + + expect(restored.tagsProvided, false); + expect(restored.tagIds, isEmpty); + expect(restored.tagNames, isEmpty); + }); + + test('parses flat merchant and tag fields', () { + final transaction = Transaction.fromJson({ + 'id': 'tx_1', + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'merchant_id': 'merchant_1', + 'merchant_name': 'Cafe', + 'tag_ids': ['tag_1', 'tag_2'], + 'tag_names': ['Work', 'Travel'], + }); + + expect(transaction.merchantId, 'merchant_1'); + expect(transaction.merchantName, 'Cafe'); + expect(transaction.tagIds, ['tag_1', 'tag_2']); + expect(transaction.tagNames, ['Work', 'Travel']); + }); + + test('normalizes mismatched flat tag name lengths', () { + final shortNames = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'tag_ids': ['tag_1', 'tag_2'], + 'tag_names': ['Work'], + }); + + final longNames = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'tag_ids': ['tag_1'], + 'tag_names': ['Work', 'Ignored'], + }); + + expect(shortNames.tagNames, ['Work', '']); + expect(shortNames.tagIds, ['tag_1', 'tag_2']); + expect(longNames.tagNames, ['Work']); + expect(longNames.tagIds, ['tag_1']); + }); + + test('filters blank flat tag ids while preserving id-name pairing', () { + final transaction = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'tag_ids': ['', 'tag_2'], + 'tag_names': ['Ignored', 'Travel'], + }); + + expect(transaction.tagIds, ['tag_2']); + expect(transaction.tagNames, ['Travel']); + }); + + test('distinguishes omitted tags from explicitly empty tags', () { + final withoutTags = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + }); + + final clearedTags = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'tags': [], + }); + + expect(withoutTags.tagsProvided, false); + expect(clearedTags.tagsProvided, true); + }); + + test('distinguishes omitted metadata from explicitly cleared metadata', () { + final withoutMetadata = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + }); + + final clearedMetadata = Transaction.fromJson({ + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'category': null, + 'merchant': null, + }); + + expect(withoutMetadata.categoryProvided, false); + expect(withoutMetadata.merchantProvided, false); + expect(clearedMetadata.categoryProvided, true); + expect(clearedMetadata.categoryId, isNull); + expect(clearedMetadata.categoryName, isNull); + expect(clearedMetadata.merchantProvided, true); + expect(clearedMetadata.merchantId, isNull); + expect(clearedMetadata.merchantName, isNull); + }); + + test('server sync merge preserves omitted metadata and applies clears', () { + final existing = OfflineTransaction( + id: 'tx_1', + localId: 'local_1', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: r'$4.50', + currency: 'USD', + nature: 'expense', + categoryId: 'cat_1', + categoryName: 'Dining', + merchantId: 'merchant_1', + merchantName: 'Cafe', + tagIds: const ['tag_1'], + tagNames: const ['Work'], + ); + + final omittedMetadata = existing.mergeServerTransaction( + Transaction.fromJson({ + 'id': 'tx_1', + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + }), + accountId: 'acct_1', + ); + + expect(omittedMetadata.categoryId, 'cat_1'); + expect(omittedMetadata.categoryName, 'Dining'); + expect(omittedMetadata.merchantId, 'merchant_1'); + expect(omittedMetadata.merchantName, 'Cafe'); + expect(omittedMetadata.tagIds, ['tag_1']); + expect(omittedMetadata.tagNames, ['Work']); + + final clearedMetadata = existing.mergeServerTransaction( + Transaction.fromJson({ + 'id': 'tx_1', + 'account_id': 'acct_1', + 'name': 'Coffee', + 'date': '2026-06-01', + 'amount': r'$4.50', + 'currency': 'USD', + 'nature': 'expense', + 'category': null, + 'merchant': null, + 'tags': [], + }), + accountId: 'acct_1', + ); + + expect(clearedMetadata.categoryId, isNull); + expect(clearedMetadata.categoryName, isNull); + expect(clearedMetadata.merchantId, isNull); + expect(clearedMetadata.merchantName, isNull); + expect(clearedMetadata.tagIds, isEmpty); + expect(clearedMetadata.tagNames, isEmpty); + }); + + test('submitted update fallback preserves account and unchanged labels', + () { + final existing = OfflineTransaction( + id: 'tx_1', + localId: 'local_1', + accountId: 'acct_1', + name: 'Coffee', + date: '2026-06-01', + amount: r'$4.50', + currency: 'USD', + nature: 'expense', + notes: 'latte', + categoryId: 'cat_1', + categoryName: 'Dining', + merchantId: 'merchant_1', + merchantName: 'Cafe', + tagIds: const ['tag_1', 'tag_2'], + tagNames: const ['Work', 'Travel'], + ); + + final updated = existing.toTransactionWithSubmittedUpdate( + name: 'Morning coffee', + notes: '', + categoryId: 'cat_2', + merchantId: 'merchant_1', + tagIds: const ['tag_2'], + ); + + expect(updated.id, 'tx_1'); + expect(updated.accountId, 'acct_1'); + expect(updated.name, 'Morning coffee'); + expect(updated.notes, ''); + expect(updated.categoryId, 'cat_2'); + expect(updated.categoryName, isNull); + expect(updated.merchantId, 'merchant_1'); + expect(updated.merchantName, 'Cafe'); + expect(updated.tagIds, ['tag_2']); + expect(updated.tagNames, ['Travel']); + }); + }); +} diff --git a/mobile/test/services/transactions_service_test.dart b/mobile/test/services/transactions_service_test.dart new file mode 100644 index 000000000..abc2e54a4 --- /dev/null +++ b/mobile/test/services/transactions_service_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:sure_mobile/services/transactions_service.dart'; + +void main() { + group('TransactionsService', () { + test('preserves field-level update errors', () async { + final service = TransactionsService( + client: MockClient((request) async { + expect(request.method, 'PATCH'); + return http.Response( + '{"message":"Transaction could not be updated",' + '"errors":{"name":["is too long"],' + '"notes":["contains unsupported characters"]}}', + 422, + ); + }), + ); + + final result = await service.updateTransaction( + accessToken: 'token', + transactionId: 'tx_1', + name: 'Coffee', + ); + + expect(result['success'], false); + expect( + result['error'], + 'Transaction could not be updated: Name is too long; Notes contains unsupported characters', + ); + }); + + test('returns null transaction for empty successful update responses', + () async { + final service = TransactionsService( + client: MockClient((request) async { + expect(request.method, 'PATCH'); + return http.Response('', 204); + }), + ); + + final result = await service.updateTransaction( + accessToken: 'token', + transactionId: 'tx_1', + name: 'Coffee', + ); + + expect(result['success'], true); + expect(result['transaction'], isNull); + }); + + test('fetches one transaction after an empty update response', () async { + var requestCount = 0; + final service = TransactionsService( + client: MockClient((request) async { + requestCount += 1; + expect(request.method, 'GET'); + return http.Response( + '{"id":"tx_1","account":{"id":"acct_1"},"name":"Coffee",' + '"date":"2026-06-01","amount":"\$4.50","currency":"USD",' + '"classification":"expense"}', + 200, + ); + }), + ); + + final result = await service.getTransaction( + accessToken: 'token', + transactionId: 'tx_1', + ); + + expect(requestCount, 1); + expect(result['success'], true); + expect(result['transaction'].name, 'Coffee'); + expect(result['transaction'].accountId, 'acct_1'); + }); + }); +} diff --git a/mobile/test/utils/amount_parser_test.dart b/mobile/test/utils/amount_parser_test.dart new file mode 100644 index 000000000..2d21152e6 --- /dev/null +++ b/mobile/test/utils/amount_parser_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/utils/amount_parser.dart'; + +void main() { + group('AmountParser', () { + test('parses decimal-comma currency values', () { + final amount = AmountParser.parse('Rp1.234,56', locale: 'id_ID'); + + expect(amount.value, 1234.56); + expect(amount.canonicalValue, '1234.56'); + expect(amount.displayText, 'Rp1.234,56'); + }); + + test('parses grouped zero-decimal locale values as whole amounts', () { + final amount = AmountParser.parse('1.234.567', locale: 'id_ID'); + + expect(amount.value, 1234567); + expect(amount.canonicalValue, '1234567'); + }); + + test('parses decimal-dot currency values', () { + final amount = AmountParser.parse(r'$1,234.56', locale: 'en_US'); + + expect(amount.value, 1234.56); + expect(amount.canonicalValue, '1234.56'); + expect(amount.displayText, r'$1,234.56'); + }); + + test('uses locale separators for ambiguous single separators', () { + expect( + AmountParser.parse('1.234', locale: 'id_ID').canonicalValue, + '1234', + ); + expect( + AmountParser.parse('1.234', locale: 'en_US').canonicalValue, + '1.234', + ); + }); + + test('normalizes minus variants and removes sign from display text', () { + final amount = AmountParser.parse('\u2212Rp1.234,50', locale: 'id_ID'); + + expect(amount.value, -1234.5); + expect(amount.canonicalValue, '-1234.5'); + expect(amount.displayText, 'Rp1.234,50'); + }); + + test('removes embedded and trailing signs from display text', () { + expect( + AmountParser.parse('Rp-1.234,50', locale: 'id_ID').displayText, + 'Rp1.234,50', + ); + expect( + AmountParser.parse('Rp1.234,50-', locale: 'id_ID').displayText, + 'Rp1.234,50', + ); + }); + + test('canonicalizes transaction form input for API payloads', () { + expect(AmountParser.canonicalize('1.234,50', locale: 'id_ID'), '1234.5'); + expect(AmountParser.canonicalize('1,234.50', locale: 'en_US'), '1234.5'); + }); + + test('rejects repeated separators that are not grouped', () { + expect( + () => AmountParser.parse('1.2.3', locale: 'en_US'), + throwsFormatException, + ); + expect( + () => AmountParser.parse('1,2,3', locale: 'id_ID'), + throwsFormatException, + ); + }); + + test('rejects inputs without digits', () { + expect( + () => AmountParser.parse('USD', locale: 'en_US'), + throwsFormatException, + ); + }); + }); +} diff --git a/spec/requests/api/v1/import_sessions_spec.rb b/spec/requests/api/v1/import_sessions_spec.rb new file mode 100644 index 000000000..02c9af9f9 --- /dev/null +++ b/spec/requests/api/v1/import_sessions_spec.rb @@ -0,0 +1,430 @@ +# frozen_string_literal: true + +require 'swagger_helper' + +RSpec.describe 'API V1 Import Sessions', type: :request do + let(:user) { users(:empty) } + let(:family) { user.family } + + let(:api_key) { api_keys(:active_key) } + let(:api_key_without_write_scope) { api_keys(:one) } + let(:api_key_without_read_scope) { api_keys(:expired_key) } + + let(:'X-Api-Key') { api_key.plain_key } + + let(:entity_ndjson) do + { + type: 'Account', + data: { + id: 'docs-account-1', + name: 'Docs Checking', + balance: '100.00', + currency: 'USD', + accountable_type: 'Depository' + } + }.to_json + end + + let(:transaction_ndjson) do + { + type: 'Transaction', + data: { + id: 'docs-transaction-1', + account_id: 'docs-account-1', + date: '2024-01-15', + amount: '-12.34', + currency: 'USD', + name: 'Docs Transaction' + } + }.to_json + end + + path '/api/v1/import_sessions' do + post 'Create import session' do + description 'Create or idempotently retrieve a multi-file SureImport session keyed by client_session_id.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json' + produces 'application/json' + + parameter name: :body, in: :body, required: false, schema: { + type: :object, + properties: { + type: { + type: :string, + enum: %w[SureImport], + description: 'Import session type. Only SureImport is supported.' + }, + client_session_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for the full import session.' + }, + expected_chunks: { + type: :integer, + minimum: 1, + nullable: true, + description: 'Expected number of ordered chunks before publish is allowed.' + } + } + } + + response '201', 'import session created' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + let(:body) do + { + type: 'SureImport', + client_session_id: 'docs-session-1', + expected_chunks: 2 + } + end + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + let(:body) { { type: 'SureImport' } } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_write_scope.plain_key } + let(:body) { { type: 'SureImport' } } + + run_test! + end + + response '409', 'client session conflict' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + family.import_sessions.create!( + client_session_id: 'docs-session-conflict', + expected_chunks: 1 + ) + end + + let(:body) do + { + type: 'SureImport', + client_session_id: 'docs-session-conflict', + expected_chunks: 2 + } + end + + run_test! + end + + response '422', 'validation error' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) { { type: 'TransactionImport' } } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 1) } + + get 'Retrieve import session' do + description 'Retrieve import session status, chunk status, per-entity summary counts, and safe error details.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + let(:id) { import_session.id } + + response '200', 'import session retrieved' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + 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', 'import session not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}/chunks' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 2) } + let(:id) { import_session.id } + + post 'Upload import session chunk' do + description 'Attach an ordered Sure NDJSON chunk to an import session. Chunks are idempotent by sequence and client_chunk_id with content verification.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + consumes 'application/json', 'multipart/form-data' + produces 'application/json' + metadata[:operation][:requestBody] = { + required: true, + content: { + 'application/json' => { + schema: { + type: :object, + required: %w[sequence raw_file_content], + properties: { + sequence: { + type: :integer, + minimum: 1, + description: 'One-based chunk sequence. Earlier dependency chunks must have lower sequence numbers.' + }, + client_chunk_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for this chunk.' + }, + raw_file_content: { + type: :string, + description: 'Raw Sure NDJSON content. Each chunk is limited to 10MB.' + } + } + } + }, + 'multipart/form-data' => { + schema: { + type: :object, + required: %w[sequence file], + properties: { + sequence: { + type: :integer, + minimum: 1, + description: 'One-based chunk sequence. Earlier dependency chunks must have lower sequence numbers.' + }, + client_chunk_id: { + type: :string, + nullable: true, + description: 'Client-provided idempotency key for this chunk.' + }, + file: { + type: :string, + format: :binary, + description: 'Multipart Sure NDJSON file upload. Each chunk is limited to 10MB.' + } + } + } + } + } + } + + parameter name: :body, in: :body, required: false + + response '201', 'chunk uploaded' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + let(:body) do + { + sequence: 1, + client_chunk_id: 'docs-entities', + raw_file_content: entity_ndjson + } + end + + run_test! + end + + response '401', 'unauthorized' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { nil } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '403', 'insufficient scope' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:'X-Api-Key') { api_key_without_write_scope.plain_key } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '409', 'chunk conflict' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + let(:body) do + { + sequence: 1, + client_chunk_id: 'docs-entities', + raw_file_content: transaction_ndjson + } + end + + run_test! + end + + response '404', 'import session not found' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:id) { SecureRandom.uuid } + let(:body) { { sequence: 1, raw_file_content: entity_ndjson } } + + run_test! + end + + response '422', 'missing or invalid content' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:body) { { sequence: 1 } } + + run_test! + end + end + end + + path '/api/v1/import_sessions/{id}/publish' do + parameter name: :id, in: :path, type: :string, required: true, description: 'Import session ID' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 1) } + let(:id) { import_session.id } + + post 'Publish import session' do + description 'Queue ordered chunk processing for a SureImport session. Later chunks can reference source IDs mapped by earlier chunks.' + tags 'Import Sessions' + security [ { apiKeyAuth: [] } ] + produces 'application/json' + + response '202', 'import session publish queued' do + schema '$ref' => '#/components/schemas/ImportSessionResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + 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_write_scope.plain_key } + + run_test! + end + + response '422', 'max_row_count_exceeded' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + import_session.imports.update_all(rows_count: SureImport.max_row_count + 1) + end + + run_test! + end + + response '409', 'missing expected chunks' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + let(:import_session) { family.import_sessions.create!(expected_chunks: 2) } + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + run_test! + end + + response '503', 'enqueue failed' do + schema '$ref' => '#/components/schemas/ErrorResponse' + + before do + import_session.attach_chunk!( + sequence: 1, + client_chunk_id: 'docs-entities', + content: entity_ndjson, + filename: 'entities.ndjson', + content_type: 'application/x-ndjson' + ) + end + + around do |example| + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, 'queue offline' }) do + example.run + end + end + + run_test! + end + + response '404', 'import session 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 c17ec07f2..22c3dd0cc 100644 --- a/spec/swagger_helper.rb +++ b/spec/swagger_helper.rb @@ -1172,6 +1172,68 @@ RSpec.configure do |config| data: { '$ref' => '#/components/schemas/ImportDetail' } } }, + ImportSessionChunk: { + type: :object, + required: %w[id sequence status rows_count summary created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + sequence: { type: :integer, minimum: 1 }, + client_chunk_id: { type: :string, nullable: true }, + status: { type: :string, enum: %w[pending importing complete failed] }, + rows_count: { type: :integer, minimum: 0 }, + summary: { + type: :object, + additionalProperties: { + type: :object, + additionalProperties: { type: :integer } + } + }, + error: { + type: :object, + nullable: true, + additionalProperties: true + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ImportSession: { + type: :object, + required: %w[id type status chunks_count summary chunks created_at updated_at], + properties: { + id: { type: :string, format: :uuid }, + type: { type: :string, enum: %w[SureImport] }, + status: { type: :string, enum: %w[pending importing complete failed] }, + client_session_id: { type: :string, nullable: true }, + expected_chunks: { type: :integer, nullable: true, minimum: 1 }, + chunks_count: { type: :integer, minimum: 0 }, + summary: { + type: :object, + additionalProperties: { + type: :object, + additionalProperties: { type: :integer } + } + }, + error: { + type: :object, + nullable: true, + additionalProperties: true + }, + chunks: { + type: :array, + items: { '$ref' => '#/components/schemas/ImportSessionChunk' } + }, + created_at: { type: :string, format: :'date-time' }, + updated_at: { type: :string, format: :'date-time' } + } + }, + ImportSessionResponse: { + type: :object, + required: %w[data], + properties: { + data: { '$ref' => '#/components/schemas/ImportSession' } + } + }, ProviderConnectionInstitution: { type: :object, required: %w[name], diff --git a/test/components/DS/menu_item_test.rb b/test/components/DS/menu_item_test.rb new file mode 100644 index 000000000..944d3d47e --- /dev/null +++ b/test/components/DS/menu_item_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class DS::MenuItemTest < ViewComponent::TestCase + test "selectable item reserves a fixed-width check gutter and shows the check when selected" do + render_inline(DS::MenuItem.new(variant: :link, text: "30D", href: "/", selected: true)) + + assert_selector "span.shrink-0.w-5", count: 1 + assert_selector "span.shrink-0.w-5 svg", count: 1 + # Selection is exposed to assistive tech, not only visually. + assert_selector "a[role='menuitemradio'][aria-checked='true']" + assert_text "30D" + end + + test "selectable item keeps the reserved gutter (no glyph) when not selected" do + render_inline(DS::MenuItem.new(variant: :link, text: "90D", href: "/", selected: false)) + + # Gutter still present so text stays aligned with the selected row... + assert_selector "span.shrink-0.w-5", count: 1 + # ...but no check glyph is drawn. + assert_no_selector "span.shrink-0.w-5 svg" + assert_selector "a[role='menuitemradio'][aria-checked='false']" + end + + test "plain action item (selected: nil) renders no check gutter" do + render_inline(DS::MenuItem.new(variant: :link, text: "Edit", href: "/", icon: "pencil-line")) + + assert_no_selector "span.shrink-0.w-5" + assert_selector "a[role='menuitem']" + assert_no_selector "[aria-checked]" + assert_text "Edit" + end +end diff --git a/test/components/UI/period_picker_test.rb b/test/components/UI/period_picker_test.rb new file mode 100644 index 000000000..ee54db16e --- /dev/null +++ b/test/components/UI/period_picker_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class UI::PeriodPickerTest < ViewComponent::TestCase + test "renders one menuitemradio link per period, all carrying the period param and target frame" do + render_inline(UI::PeriodPicker.new(selected: "last_30_days", url: "/", frame: "dashboard_sections")) + + links = page.all("a[role='menuitemradio']") + assert_equal Period.all.size, links.size + + links.each do |link| + assert_match(/period=/, link[:href]) + assert_equal "dashboard_sections", link["data-turbo-frame"] + end + end + + test "marks the selected period with aria-checked and a check glyph" do + render_inline(UI::PeriodPicker.new(selected: "last_90_days", url: "/", frame: "dashboard_sections")) + + selected = page.all("a[role='menuitemradio'][aria-checked='true']") + assert_equal 1, selected.size + assert_match(/period=last_90_days/, selected.first[:href]) + # Check icon (svg) renders inside the selected item only. + assert_selector "a[aria-checked='true'] svg" + end + + test "trigger button shows the selected period's short label" do + render_inline(UI::PeriodPicker.new(selected: "last_30_days", url: "/")) + + assert_text Period.from_key("last_30_days").label_short + end + + test "trigger accessible name announces the selected period" do + render_inline(UI::PeriodPicker.new(selected: "last_90_days", url: "/")) + + label = Period.from_key("last_90_days").label_short + assert_selector "button[aria-label='Time period: #{label}']" + end + + test "extra_params are merged into every option href" do + render_inline(UI::PeriodPicker.new( + selected: "last_30_days", + url: "/accounts/abc", + extra_params: { chart_view: "balance" } + )) + + href = page.first("a[role='menuitemradio']")[:href] + assert_match(%r{\A/accounts/abc\?}, href) + assert_match(/chart_view=balance/, href) + assert_match(/period=/, href) + end + + test "accepts a Period object as selected" do + render_inline(UI::PeriodPicker.new(selected: Period.last_7_days, url: "/")) + + assert_text Period.from_key("last_7_days").label_short + assert_equal 1, page.all("a[aria-checked='true']").size + end +end diff --git a/test/components/previews/menu_component_preview.rb b/test/components/previews/menu_component_preview.rb index 6fffb7ca3..002aa7969 100644 --- a/test/components/previews/menu_component_preview.rb +++ b/test/components/previews/menu_component_preview.rb @@ -18,6 +18,18 @@ class MenuComponentPreview < ViewComponent::Preview end end + # Single-select list. `selected:` reserves a fixed-width leading check gutter + # so the selected row's text stays aligned with every other row. + def selectable + render DS::Menu.new(variant: "button") do |menu| + menu.with_button(text: "30D", variant: "secondary") + menu.with_item(variant: "link", text: "7D", href: "#", selected: false) + menu.with_item(variant: "link", text: "30D", href: "#", selected: true) + menu.with_item(variant: "link", text: "90D", href: "#", selected: false) + menu.with_item(variant: "link", text: "Year to Date", href: "#", selected: false) + end + end + private def menu_contents(menu) menu.with_item(variant: "link", text: "Link", href: "#", icon: "plus") diff --git a/test/controllers/api/v1/import_sessions_controller_test.rb b/test/controllers/api/v1/import_sessions_controller_test.rb new file mode 100644 index 000000000..3a90f361d --- /dev/null +++ b/test/controllers/api/v1/import_sessions_controller_test.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require "test_helper" + +class Api::V1::ImportSessionsControllerTest < ActionDispatch::IntegrationTest + include ActiveJob::TestHelper + + setup do + @user = users(:family_admin) + @family = @user.family + @api_key = api_keys(:active_key) + @read_only_api_key = api_keys(:one) + + Redis.new.del("api_rate_limit:#{@api_key.id}") + Redis.new.del("api_rate_limit:#{@read_only_api_key.id}") + end + + test "creates an idempotent Sure import session" do + assert_difference("ImportSession.count", 1) do + post api_v1_import_sessions_url, + params: { + type: "SureImport", + client_session_id: "client-session-1", + expected_chunks: 2 + }, + headers: api_headers(@api_key) + end + + assert_response :created + first_id = JSON.parse(response.body).dig("data", "id") + + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { + type: "SureImport", + client_session_id: "client-session-1", + expected_chunks: 2 + }, + headers: api_headers(@api_key) + end + + assert_response :created + assert_equal first_id, JSON.parse(response.body).dig("data", "id") + end + + test "rejects unsupported import session types" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "TransactionImport" }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + assert_equal "validation_failed", JSON.parse(response.body)["error"] + end + + test "rejects malformed expected chunk counts" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "SureImport", expected_chunks: "2abc" }, + headers: api_headers(@api_key) + end + + assert_response :unprocessable_entity + assert_equal "validation_failed", JSON.parse(response.body)["error"] + end + + test "requires authentication for session creation" do + post api_v1_import_sessions_url, params: { type: "SureImport" } + + assert_response :unauthorized + assert_equal "unauthorized", JSON.parse(response.body)["error"] + end + + test "uploads ordered chunks and publishes a full-fidelity transaction import" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { + sequence: 1, + client_chunk_id: "entities", + raw_file_content: build_ndjson(entity_records) + }, + headers: api_headers(@api_key) + + assert_response :created + assert_equal 1, JSON.parse(response.body).dig("data", "chunks_count") + + post chunks_api_v1_import_session_url(session), + params: { + sequence: 2, + client_chunk_id: "transactions", + raw_file_content: build_ndjson(transaction_records) + }, + headers: api_headers(@api_key) + + assert_response :created + + perform_enqueued_jobs do + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + end + + assert_response :accepted + session.reload + assert session.complete? + assert_equal 1, session.summary.dig("transactions", "created") + + entry = @family.accounts.find_by!(name: "API Session Checking").entries.find_by!(name: "API Grocery Run") + transaction = entry.entryable + assert_equal "API Groceries", transaction.category.name + assert_equal "API Market", transaction.merchant.name + assert_equal [ "API Weekly" ], transaction.tags.map(&:name) + end + + test "rejects replayed chunk with different content" do + session = build_import_session + params = { + sequence: 1, + client_chunk_id: "entities", + raw_file_content: build_ndjson(entity_records) + } + + post chunks_api_v1_import_session_url(session), params: params, headers: api_headers(@api_key) + assert_response :created + + post chunks_api_v1_import_session_url(session), + params: params.merge(raw_file_content: build_ndjson(transaction_records)), + headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "requires chunk sequence" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { raw_file_content: build_ndjson(entity_records) }, + headers: api_headers(@api_key) + + assert_response :bad_request + assert_equal "bad_request", JSON.parse(response.body)["error"] + end + + test "rejects malformed chunk sequence values" do + session = build_import_session + + post chunks_api_v1_import_session_url(session), + params: { sequence: "1abc", raw_file_content: build_ndjson(entity_records) }, + headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "shows import session with read scope" do + session = build_import_session + + get api_v1_import_session_url(session), headers: api_headers(@read_only_api_key) + + assert_response :success + data = JSON.parse(response.body)["data"] + assert_equal session.id, data["id"] + assert_equal "SureImport", data["type"] + end + + test "shows chunks in sequence order" do + session = build_import_session + session.imports.create!( + family: @family, + type: "SureImport", + sequence: 2, + checksum: Digest::SHA256.hexdigest("two") + ) + session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + checksum: Digest::SHA256.hexdigest("one") + ) + + get api_v1_import_session_url(session), headers: api_headers(@api_key) + + assert_response :success + assert_equal [ 1, 2 ], JSON.parse(response.body).dig("data", "chunks").map { |chunk| chunk["sequence"] } + end + + test "requires write scope for session mutation" do + assert_no_difference("ImportSession.count") do + post api_v1_import_sessions_url, + params: { type: "SureImport" }, + headers: api_headers(@read_only_api_key) + end + + assert_response :forbidden + assert_equal "insufficient_scope", JSON.parse(response.body)["error"] + end + + test "rejects publishing a session with no chunks" do + session = @family.import_sessions.create! + + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + + assert_response :conflict + assert_equal "import_session_conflict", JSON.parse(response.body)["error"] + end + + test "returns stable error when publish cannot enqueue" do + session = build_import_session + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, "redis://secret.local/0" }) do + post publish_api_v1_import_session_url(session), headers: api_headers(@api_key) + end + + assert_response :service_unavailable + body = JSON.parse(response.body) + assert_equal "import_enqueue_failed", body["error"] + assert_equal "Import session could not be queued.", body["message"] + assert_no_match(/secret/, response.body) + end + + test "does not expose another family's import session" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_session = other_family.import_sessions.create! + + get api_v1_import_session_url(other_session), headers: api_headers(@api_key) + + assert_response :not_found + end + + private + def build_import_session + @family.import_sessions.create!(expected_chunks: 2) + end + + def entity_records + [ + { + type: "Account", + data: { + id: "api-acct-1", + name: "API Session Checking", + balance: "100.00", + currency: "USD", + accountable_type: "Depository" + } + }, + { + type: "Category", + data: { + id: "api-cat-1", + name: "API Groceries", + color: "#407706", + classification: "expense" + } + }, + { + type: "Merchant", + data: { + id: "api-merchant-1", + name: "API Market" + } + }, + { + type: "Tag", + data: { + id: "api-tag-1", + name: "API Weekly" + } + } + ] + end + + def transaction_records + [ + { + type: "Transaction", + data: { + id: "api-txn-1", + account_id: "api-acct-1", + category_id: "api-cat-1", + merchant_id: "api-merchant-1", + tag_ids: [ "api-tag-1" ], + date: "2024-01-15", + amount: "-12.34", + currency: "USD", + name: "API Grocery Run" + } + } + ] + end + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end +end diff --git a/test/controllers/api/v1/users_controller_test.rb b/test/controllers/api/v1/users_controller_test.rb index 570c0bd9d..32d69c5f9 100644 --- a/test/controllers/api/v1/users_controller_test.rb +++ b/test/controllers/api/v1/users_controller_test.rb @@ -116,6 +116,20 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest end test "reset status returns family data counts" do + import_session = @user.family.import_sessions.create!(expected_chunks: 1) + import_session.imports.create!( + family: @user.family, + type: "SureImport", + sequence: 1, + checksum: "a" * 64 + ) + import_session.source_mappings.create!( + family: @user.family, + source_type: "Category", + source_id: "source-category-1", + target: @user.family.categories.first + ) + get "/api/v1/users/reset/status", headers: api_headers(@read_only_api_key) assert_response :ok @@ -124,6 +138,8 @@ class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest assert_includes %w[complete data_remaining], body["status"] assert_equal body["counts"].values.sum.zero?, body["reset_complete"] assert_equal expected_reset_count_keys.sort, body["counts"].keys.sort + assert_equal 1, body["counts"]["import_sessions"] + assert_equal 1, body["counts"]["import_source_mappings"] end test "reset status ignores the follow-up family sync after reset" do diff --git a/test/controllers/depositories_controller_test.rb b/test/controllers/depositories_controller_test.rb index 3c6e443a5..9ce0eb70b 100644 --- a/test/controllers/depositories_controller_test.rb +++ b/test/controllers/depositories_controller_test.rb @@ -7,4 +7,35 @@ class DepositoriesControllerTest < ActionDispatch::IntegrationTest sign_in @user = users(:family_admin) @account = accounts(:depository) end + + test "create falls back to the stored return_to when no form param is present" do + get new_account_path(return_to: transactions_path) # StoreLocation captures it into the session + + assert_difference -> { Account.count } => 1 do + post depositories_path, params: { + account: { name: "Return To Checking", currency: "USD", balance: 100, accountable_type: "Depository" } + } + end + + assert_redirected_to transactions_path + end + + test "create prefers the form return_to over the session value" do + get new_account_path(return_to: transactions_path) # session return_to + + post depositories_path, params: { + account: { name: "Form RT Checking", currency: "USD", balance: 100, accountable_type: "Depository", return_to: budgets_path } + } + + assert_redirected_to budgets_path + end + + test "create ignores an external return_to (open-redirect guard)" do + post depositories_path, params: { + account: { name: "Evil RT Checking", currency: "USD", balance: 100, accountable_type: "Depository", return_to: "https://evil.example/phish" } + } + + created = Account.order(:created_at).last + assert_redirected_to account_path(created) # not the external URL + end end diff --git a/test/controllers/enable_banking_items_controller_test.rb b/test/controllers/enable_banking_items_controller_test.rb index 06005bc9a..424a5aa64 100644 --- a/test/controllers/enable_banking_items_controller_test.rb +++ b/test/controllers/enable_banking_items_controller_test.rb @@ -39,4 +39,28 @@ class EnableBankingItemsControllerTest < ActionDispatch::IntegrationTest assert_includes haystack, "ing-diba ag", "Expected the searchable data attribute to still include the bank name (existing name-search behavior)" end + + test "authorize no longer blocks decoupled banks and proceeds to the hosted auth page" do + Provider::EnableBanking.any_instance.stubs(:get_aspsps).returns( + aspsps: [ + { + name: "VR Bank in Holstein", + country: "DE", + psu_types: [ "personal" ], + auth_methods: [ { name: "decoupled_app", approach: "DECOUPLED" } ] + } + ] + ) + Provider::EnableBanking.any_instance.stubs(:start_authorization).returns( + url: "https://api.enablebanking.com/auth/redirect/abc", + authorization_id: "auth_1" + ) + + post authorize_enable_banking_item_url(@item), + params: { aspsp_name: "VR Bank in Holstein", psu_type: "personal" } + + assert_redirected_to "https://api.enablebanking.com/auth/redirect/abc" + assert_nil flash[:alert] + assert_equal "DECOUPLED", @item.reload.aspsp_auth_approach + end end diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb index c20ae7e73..2fb08236c 100644 --- a/test/controllers/goals_controller_test.rb +++ b/test/controllers/goals_controller_test.rb @@ -90,6 +90,47 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_response :unprocessable_entity end + test "new form excludes same-family accounts not shared with the current user" do + # Regression for #2168: funding-account picker leaked accounts owned by + # other family members that were never shared with the current user. + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: "USD", + balance: 100 + ) + + get new_goal_url + assert_response :success + assert_no_match(/Member Private Checking/, response.body) + assert_no_match(/goal_account_ids_#{private_account.id}/, response.body) + end + + test "create rejects a same-family account not shared with the current user" do + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: "USD", + balance: 100 + ) + + assert_no_difference "Goal.count" do + post goals_url, params: { + goal: { + name: "Sneaky goal", + target_amount: "1000", + color: "#4da568", + account_ids: [ private_account.id ] + } + } + end + assert_response :unprocessable_entity + end + test "update modifies identity fields" do patch goal_url(@goal), params: { goal: { name: "Renamed" } } assert_redirected_to goal_path(@goal) @@ -109,6 +150,28 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest assert_equal [ @connected.id ], @goal.reload.goal_accounts.pluck(:account_id) end + test "update preserves a linked account the current user cannot access" do + # Regression for #2172 review: a family goal can be linked to a private + # account owned by another member. That account is never rendered in the + # picker, so its absence from the submitted set must not unlink it. + private_account = Account.create!( + family: @user.family, + owner: users(:family_member), + accountable: Depository.new, + name: "Member Private Checking", + currency: @goal.currency, + balance: 100 + ) + @goal.goal_accounts.create!(account: private_account) + + patch goal_url(@goal), params: { goal: { account_ids: [ @depository.id ] } } + + assert_redirected_to goal_path(@goal) + linked = @goal.reload.goal_accounts.pluck(:account_id) + assert_includes linked, private_account.id, "inaccessible private link must be preserved" + assert_includes linked, @depository.id + end + test "update with empty account_ids re-renders with error" do patch goal_url(@goal), params: { goal: { account_ids: [ "" ] } } assert_response :unprocessable_entity diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb index ce5a97847..81ecd88f8 100644 --- a/test/controllers/properties_controller_test.rb +++ b/test/controllers/properties_controller_test.rb @@ -188,4 +188,89 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest assert draft_account.active? assert_redirected_to account_path(draft_account) end + + test "address update on draft account honors stored return_to over the account page" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property RT", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # The property wizard (create → balances → address) doesn't thread return_to + # as a form param, so StoreLocation's session value is the only carrier. + get new_account_path(return_to: transactions_path) + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "789 Activate St", + locality: "New York", + region: "NY", + country: "US", + postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to transactions_path + end + + test "address update ignores an external stored return_to (open-redirect guard)" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property Evil", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # A hostile ?return_to is rejected at store time, so the wizard falls back + # to the account page rather than stream-redirecting off-site. + get new_account_path(return_to: "https://evil.example/phish") + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to account_path(draft_account) + end + + test "address update tolerates a non-String stored return_to without raising" do + draft_account = Account.create!( + family: @user.family, + name: "Draft Property Array", + accountable: Property.new, + status: "draft", + balance: 500000, + currency: "USD" + ) + + # `?return_to[]=foo` makes params[:return_to] an Array; safe_return_to must + # reject it via the is_a?(String) guard instead of raising NoMethodError. + get new_account_path("return_to" => [ "/transactions" ]) + + patch update_address_property_path(draft_account), params: { + property: { + address_attributes: { + line1: "1 Safe St", locality: "NYC", region: "NY", country: "US", postal_code: "10001" + } + } + } + + draft_account.reload + assert draft_account.active? + assert_redirected_to account_path(draft_account) + end end diff --git a/test/jobs/family_reset_job_test.rb b/test/jobs/family_reset_job_test.rb index d5979f3f7..739df4b60 100644 --- a/test/jobs/family_reset_job_test.rb +++ b/test/jobs/family_reset_job_test.rb @@ -10,10 +10,25 @@ class FamilyResetJobTest < ActiveJob::TestCase test "resets family data successfully" do initial_account_count = @family.accounts.count initial_category_count = @family.categories.count + import_session = @family.import_sessions.create!(expected_chunks: 1) + import_session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + checksum: "a" * 64 + ) + import_session.source_mappings.create!( + family: @family, + source_type: "Category", + source_id: "source-category-1", + target: @family.categories.first + ) # Family should have existing data assert initial_account_count > 0 assert initial_category_count > 0 + assert_equal 1, @family.import_sessions.count + assert_equal 1, @family.import_source_mappings.count # Don't expect Plaid removal calls since we're using fixtures without setup @plaid_provider.stubs(:remove_item) @@ -23,6 +38,38 @@ class FamilyResetJobTest < ActiveJob::TestCase # All data should be removed assert_equal 0, @family.accounts.reload.count assert_equal 0, @family.categories.reload.count + assert_equal 0, @family.import_sessions.reload.count + assert_equal 0, @family.import_source_mappings.reload.count + assert_equal 0, @family.imports.reload.count + end + + test "reset leaves another family's imports and mappings untouched" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_category = other_family.categories.create!(name: "Other Category") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_import = other_session.imports.create!( + family: other_family, + type: "SureImport", + sequence: 1, + checksum: "b" * 64 + ) + other_mapping = other_session.source_mappings.create!( + family: other_family, + source_type: "Category", + source_id: "source-category-1", + target: other_category + ) + + @family.import_sessions.create!(expected_chunks: 1) + @plaid_provider.stubs(:remove_item) + + FamilyResetJob.perform_now(@family) + + assert ImportSession.exists?(other_session.id) + assert Import.exists?(other_import.id) + assert ImportSourceMapping.exists?(other_mapping.id) + assert Category.exists?(other_category.id) + assert_equal other_category, other_mapping.reload.target end test "resets family data even when Plaid credentials are invalid" do diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb index 7e180ef36..47049964f 100644 --- a/test/models/balance/chart_series_builder_test.rb +++ b/test/models/balance/chart_series_builder_test.rb @@ -96,6 +96,24 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase assert_equal expected, builder.balance_series.map { |v| v.value.amount } end + test "account active until dates stop locf while preserving date rows" do + account = accounts(:depository) + account.balances.destroy_all + + period = Period.custom(start_date: 2.days.ago.to_date, end_date: Date.current) + create_balance(account: account, date: period.start_date, balance: 1000) + + builder = Balance::ChartSeriesBuilder.new( + account_ids: [ account.id ], + account_active_until_dates: { account.id => 1.day.ago.to_date }, + currency: "USD", + period: period, + interval: "1 day" + ) + + assert_equal [ 1000, 1000, 0 ], builder.balance_series.map { |v| v.value.amount } + end + test "when favorable direction is down balance signage inverts" do account = accounts(:credit_card) account.balances.destroy_all diff --git a/test/models/balance_sheet_test.rb b/test/models/balance_sheet_test.rb index 558657279..3d178b575 100644 --- a/test/models/balance_sheet_test.rb +++ b/test/models/balance_sheet_test.rb @@ -1,6 +1,8 @@ require "test_helper" class BalanceSheetTest < ActiveSupport::TestCase + include BalanceTestHelper + setup do @family = families(:empty) end @@ -46,6 +48,47 @@ class BalanceSheetTest < ActiveSupport::TestCase assert_equal 1000, BalanceSheet.new(@family).liabilities.total end + test "net worth series preserves disabled history without carrying it into current totals" do + period = Period.custom(start_date: Date.current - 1.day, end_date: Date.current) + active_account = create_account(balance: 20_000, accountable: Depository.new) + disabled_account = create_account(balance: 0, accountable: Depository.new) + pending_deletion_account = create_account(balance: 0, accountable: Depository.new) + disabled_account.disable! + pending_deletion_account.mark_for_deletion! + + assert_not_nil disabled_account.reload.disabled_at + + create_balance(account: active_account, date: period.start_date, balance: 10_000) + create_balance(account: active_account, date: period.end_date, balance: 20_000) + create_balance(account: disabled_account, date: period.start_date, balance: 20_000) + create_balance(account: disabled_account, date: period.end_date, balance: 10_000) + create_balance(account: pending_deletion_account, date: period.start_date, balance: 40_000) + create_balance(account: pending_deletion_account, date: period.end_date, balance: 80_000) + + series = BalanceSheet.new(@family).net_worth_series(period: period) + values_by_date = series.values.index_by(&:date) + + assert_equal 30_000, values_by_date.fetch(period.start_date).value.amount + assert_equal 20_000, BalanceSheet.new(@family).net_worth + assert_equal BalanceSheet.new(@family).net_worth, values_by_date.fetch(period.end_date).value.amount + end + + test "historical account scope respects shared-account finance settings" do + member = users(:new_email) + included_account = create_account(balance: 0, accountable: Depository.new) + excluded_account = create_account(balance: 0, accountable: Depository.new) + + included_account.disable! + excluded_account.disable! + included_account.share_with!(member, include_in_finances: true) + excluded_account.share_with!(member, include_in_finances: false) + + account_ids = BalanceSheet::HistoricalAccountScope.new(@family, user: member).account_ids + + assert_includes account_ids, included_account.id + assert_not_includes account_ids, excluded_account.id + end + test "calculates asset group totals" do create_account(balance: 1000, accountable: Depository.new) create_account(balance: 2000, accountable: Depository.new) diff --git a/test/models/enable_banking_item/importer_error_handling_test.rb b/test/models/enable_banking_item/importer_error_handling_test.rb index e5b9192cb..006ce127a 100644 --- a/test/models/enable_banking_item/importer_error_handling_test.rb +++ b/test/models/enable_banking_item/importer_error_handling_test.rb @@ -19,22 +19,43 @@ class EnableBankingItem::ImporterErrorHandlingTest < ActiveSupport::TestCase @importer = EnableBankingItem::Importer.new(@enable_banking_item, enable_banking_provider: @mock_provider) end - test "handle_sync_error handles unauthorized EnableBankingError" do + # Session-level auth failures (the top-level GET /sessions call) mean the consent + # is genuinely dead and the user must re-authorize. + test "handle_sync_error with session_level flips requires_update on unauthorized" do error = Provider::EnableBanking::EnableBankingError.new("Unauthorized", :unauthorized) - message = @importer.send(:handle_sync_error, error) + message = @importer.send(:handle_sync_error, error, session_level: true) assert_equal I18n.t("enable_banking_items.errors.session_invalid"), message assert @enable_banking_item.reload.requires_update? end - test "handle_sync_error handles not_found EnableBankingError" do + test "handle_sync_error with session_level flips requires_update on not_found" do error = Provider::EnableBanking::EnableBankingError.new("Not Found", :not_found) - message = @importer.send(:handle_sync_error, error) + message = @importer.send(:handle_sync_error, error, session_level: true) assert_equal I18n.t("enable_banking_items.errors.session_invalid"), message assert @enable_banking_item.reload.requires_update? end + # Per-account auth failures (a stale account UID, a transient hiccup on one + # account) must NOT kill the whole connection — that is what made every sync + # report "session expired". They surface as ordinary api errors and retry. + test "handle_sync_error per-account unauthorized does not flip requires_update" do + error = Provider::EnableBanking::EnableBankingError.new("Unauthorized", :unauthorized) + message = @importer.send(:handle_sync_error, error) + + assert_equal I18n.t("enable_banking_items.errors.api_error"), message + assert_not @enable_banking_item.reload.requires_update? + end + + test "handle_sync_error per-account not_found does not flip requires_update" do + error = Provider::EnableBanking::EnableBankingError.new("Not Found", :not_found) + message = @importer.send(:handle_sync_error, error) + + assert_equal I18n.t("enable_banking_items.errors.api_error"), message + assert_not @enable_banking_item.reload.requires_update? + end + test "handle_sync_error handles other EnableBankingError as api_error" do error = Provider::EnableBanking::EnableBankingError.new("Some API error", :internal_server_error) message = @importer.send(:handle_sync_error, error) @@ -53,14 +74,24 @@ class EnableBankingItem::ImporterErrorHandlingTest < ActiveSupport::TestCase assert @enable_banking_item.reload.requires_update? end - test "fetch_and_store_transactions updates status to requires_update on unauthorized error" do + test "fetch_session_data reconciles session_expires_at from API access.valid_until" do + new_expiry = 45.days.from_now.change(usec: 0) + @mock_provider.stubs(:get_session).returns({ access: { valid_until: new_expiry.iso8601 } }) + + @importer.send(:fetch_session_data) + + assert_equal new_expiry.to_i, @enable_banking_item.reload.session_expires_at.to_i + end + + test "fetch_and_store_transactions does not flip whole connection on per-account unauthorized error" do enable_banking_account = EnableBankingAccount.new(uid: "test_uid") @importer.stubs(:determine_sync_start_date).returns(Date.today) @importer.expects(:fetch_paginated_transactions).raises(Provider::EnableBanking::EnableBankingError.new("Unauthorized", :unauthorized)) - @importer.send(:fetch_and_store_transactions, enable_banking_account) + result = @importer.send(:fetch_and_store_transactions, enable_banking_account) - assert @enable_banking_item.reload.requires_update? + assert_not result[:success] + assert_not @enable_banking_item.reload.requires_update? end test "fetch_and_store_transactions succeeds and skips pending when ASPSP rejects PDNG transaction_status" do @@ -99,14 +130,15 @@ class EnableBankingItem::ImporterErrorHandlingTest < ActiveSupport::TestCase assert_not result[:success] end - test "fetch_and_update_balance updates status to requires_update on unauthorized error" do + test "fetch_and_update_balance does not flip whole connection on per-account unauthorized error" do enable_banking_account = EnableBankingAccount.new(uid: "test_uid") def @mock_provider.get_account_balances(**args) raise Provider::EnableBanking::EnableBankingError.new("Unauthorized", :unauthorized) end - @importer.send(:fetch_and_update_balance, enable_banking_account) + result = @importer.send(:fetch_and_update_balance, enable_banking_account) - assert @enable_banking_item.reload.requires_update? + assert_not result + assert_not @enable_banking_item.reload.requires_update? end end diff --git a/test/models/enable_banking_item/syncer_test.rb b/test/models/enable_banking_item/syncer_test.rb new file mode 100644 index 000000000..861db6e6c --- /dev/null +++ b/test/models/enable_banking_item/syncer_test.rb @@ -0,0 +1,31 @@ +require "test_helper" + +class EnableBankingItem::SyncerTest < ActiveSupport::TestCase + setup do + @item = EnableBankingItem.create!( + family: families(:dylan_family), + name: "Test", + country_code: "DE", + application_id: "app", + client_certificate: "cert", + session_id: "sess", + session_expires_at: 1.day.ago, # expired + status: :good + ) + @syncer = EnableBankingItem::Syncer.new(@item) + end + + test "expired session marks requires_update and finishes gracefully without raising" do + sync = Sync.create!(syncable: @item) + + assert_nothing_raised do + @syncer.perform_sync(sync) + end + + assert @item.reload.requires_update? + + stats = sync.reload.sync_stats || {} + assert_equal 0, (stats["total_errors"] || 0), + "Expired session should be a graceful reconnect state, not a red sync error" + end +end diff --git a/test/models/enable_banking_item_test.rb b/test/models/enable_banking_item_test.rb new file mode 100644 index 000000000..83e101119 --- /dev/null +++ b/test/models/enable_banking_item_test.rb @@ -0,0 +1,106 @@ +require "test_helper" + +class EnableBankingItemTest < ActiveSupport::TestCase + setup do + @item = EnableBankingItem.new( + family: families(:dylan_family), + name: "Test", + country_code: "DE", + application_id: "app", + client_certificate: "cert" + ) + end + + test "select_auth_method prefers REDIRECT over DECOUPLED and EMBEDDED" do + aspsp = { + auth_methods: [ + { name: "decoupled_app", approach: "DECOUPLED" }, + { name: "redirect_web", approach: "REDIRECT" }, + { name: "embedded_form", approach: "EMBEDDED" } + ] + }.with_indifferent_access + + selected = @item.send(:select_auth_method, aspsp, "personal") + + assert_equal "redirect_web", selected[:name] + assert_equal "REDIRECT", selected[:approach] + end + + test "select_auth_method falls back to DECOUPLED when no REDIRECT exists" do + aspsp = { + auth_methods: [ + { name: "embedded_form", approach: "EMBEDDED" }, + { name: "decoupled_app", approach: "DECOUPLED" } + ] + }.with_indifferent_access + + selected = @item.send(:select_auth_method, aspsp, "personal") + + assert_equal "decoupled_app", selected[:name] + assert_equal "DECOUPLED", selected[:approach] + end + + test "select_auth_method filters by psu_type when methods declare one" do + aspsp = { + auth_methods: [ + { name: "business_redirect", approach: "REDIRECT", psu_type: "business" }, + { name: "personal_decoupled", approach: "DECOUPLED", psu_type: "personal" } + ] + }.with_indifferent_access + + selected = @item.send(:select_auth_method, aspsp, "personal") + + assert_equal "personal_decoupled", selected[:name] + end + + test "select_auth_method ignores hidden methods" do + aspsp = { + auth_methods: [ + { name: "hidden_redirect", approach: "REDIRECT", hidden_method: true }, + { name: "decoupled_app", approach: "DECOUPLED" } + ] + }.with_indifferent_access + + selected = @item.send(:select_auth_method, aspsp, "personal") + + assert_equal "decoupled_app", selected[:name] + end + + test "select_auth_method returns nil when no auth methods present" do + assert_nil @item.send(:select_auth_method, { auth_methods: [] }.with_indifferent_access, "personal") + end + + test "select_auth_method returns nil when every method is hidden" do + aspsp = { + auth_methods: [ + { name: "hidden_a", approach: "REDIRECT", hidden_method: true }, + { name: "hidden_b", approach: "DECOUPLED", hidden_method: true } + ] + }.with_indifferent_access + + # All methods hidden -> fall back to the ASPSP default rather than forcing one. + assert_nil @item.send(:select_auth_method, aspsp, "personal") + end + + test "reconcile_session_expiry! updates session_expires_at from access.valid_until" do + @item.session_id = "sess" + @item.session_expires_at = 1.day.from_now + @item.save! + new_expiry = 60.days.from_now.change(usec: 0) + + @item.reconcile_session_expiry!({ access: { valid_until: new_expiry.iso8601 } }) + + assert_equal new_expiry.to_i, @item.reload.session_expires_at.to_i + end + + test "reconcile_session_expiry! is a no-op when valid_until is missing" do + @item.session_id = "sess" + original = 1.day.from_now.change(usec: 0) + @item.session_expires_at = original + @item.save! + + @item.reconcile_session_expiry!({ access: {} }) + + assert_equal original.to_i, @item.reload.session_expires_at.to_i + end +end diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb index 83e4ea8cc..00d4759d7 100644 --- a/test/models/family/data_exporter_test.rb +++ b/test/models/family/data_exporter_test.rb @@ -381,9 +381,12 @@ class Family::DataExporterTest < ActiveSupport::TestCase assert rule_lines.any? - rule_data = JSON.parse(rule_lines.first) + rule_data = rule_lines.map { |line| JSON.parse(line) }.find { |rule| rule["data"]["name"] == "Test Rule" } + + assert_not_nil rule_data assert_equal "Rule", rule_data["type"] assert_equal 1, rule_data["version"] + assert_equal @rule.id, rule_data["data"]["id"] assert rule_data["data"].key?("name") assert rule_data["data"].key?("resource_type") assert rule_data["data"].key?("active") diff --git a/test/models/family/data_importer_test.rb b/test/models/family/data_importer_test.rb index 0ad0c9870..21ea0d4b7 100644 --- a/test/models/family/data_importer_test.rb +++ b/test/models/family/data_importer_test.rb @@ -125,6 +125,26 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal 1, balance.flows_factor end + test "counts skipped balance rows with blank account references once" do + ndjson = build_ndjson([ + { + type: "Balance", + data: { + id: "balance-1", + account_id: "", + date: "2024-01-31", + balance: "1200.00", + currency: "USD" + } + } + ]) + + result = Family::DataImporter.new(@family, ndjson).import! + + assert_equal 1, result.dig(:summary, "balances", "skipped") + assert_not Balance.exists?(date: Date.iso8601("2024-01-31"), currency: "USD", balance: BigDecimal("1200.00")) + end + test "imports duplicate raw balance records idempotently by account date and currency" do balance_record = { type: "Balance", @@ -442,6 +462,23 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal "#FF0000", tag.color end + test "imports tags with deterministic fallback color when source omits color" do + ndjson = build_ndjson([ + { + type: "Tag", + data: { + id: "tag-1", + name: "Important" + } + } + ]) + + Family::DataImporter.new(@family, ndjson).import! + + tag = @family.tags.find_by!(name: "Important") + assert_equal Tag::COLORS.first, tag.color + end + test "imports merchants" do ndjson = build_ndjson([ { @@ -945,6 +982,98 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_empty explicit_empty_child.transaction.tags end + test "session transaction reimport only replaces current family taggings" do + session = @family.import_sessions.create!(expected_chunks: 1) + account = @family.accounts.create!( + name: "Session Checking", + accountable: Depository.new, + balance: 100, + currency: "USD" + ) + original_tag = @family.tags.create!(name: "Original") + replacement_tag = @family.tags.create!(name: "Replacement") + entry = account.entries.create!( + date: Date.parse("2024-01-01"), + amount: -10, + currency: "USD", + name: "Original transaction", + source: "sure_import_session:#{session.id}", + external_id: "Transaction:txn-1", + entryable: Transaction.new(kind: "standard") + ) + transaction = entry.entryable + transaction.taggings.create!(tag: original_tag) + + other_family = Family.create!(name: "Other Family", currency: "USD") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_account = other_family.accounts.create!( + name: "Other Checking", + accountable: Depository.new, + balance: 100, + currency: "USD" + ) + other_tag = other_family.tags.create!(name: "Other Original") + other_entry = other_account.entries.create!( + date: Date.parse("2024-01-01"), + amount: -10, + currency: "USD", + name: "Other transaction", + source: "sure_import_session:#{other_session.id}", + external_id: "Transaction:txn-1", + entryable: Transaction.new(kind: "standard") + ) + other_transaction = other_entry.entryable + other_transaction.taggings.create!(tag: other_tag) + + other_session.source_mappings.create!( + family: other_family, + source_type: "Transaction", + source_id: "txn-1", + target: other_transaction + ) + + session.source_mappings.create!( + family: @family, + source_type: "Account", + source_id: "acct-1", + target: account + ) + session.source_mappings.create!( + family: @family, + source_type: "Tag", + source_id: "tag-1", + target: replacement_tag + ) + session.source_mappings.create!( + family: @family, + source_type: "Transaction", + source_id: "txn-1", + target: transaction + ) + + ndjson = build_ndjson([ + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-1", + tag_ids: [ "tag-1" ], + date: "2024-02-01", + amount: "-12.34", + currency: "USD", + name: "Updated transaction" + } + } + ]) + + Family::DataImporter.new(@family, ndjson, import_session: session).import! + + assert_equal [ "Replacement" ], transaction.reload.tags.map(&:name) + assert_equal "Updated transaction", entry.reload.name + assert_equal [ "Other Original" ], other_transaction.reload.tags.map(&:name) + assert_equal "Other transaction", other_entry.reload.name + end + test "imports trades with securities" do ndjson = build_ndjson([ { @@ -1429,7 +1558,7 @@ class Family::DataImporterTest < ActiveSupport::TestCase amount: "100.00", name: "Transfer to savings", currency: "USD", - kind: "funds_movement" + kind: "standard" } }, { @@ -1441,7 +1570,7 @@ class Family::DataImporterTest < ActiveSupport::TestCase amount: "-100.00", name: "Transfer from checking", currency: "USD", - kind: "funds_movement" + kind: "standard" } }, { @@ -1496,6 +1625,8 @@ class Family::DataImporterTest < ActiveSupport::TestCase 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 + assert_equal "funds_movement", transfer.inflow_transaction.kind + assert_equal "funds_movement", transfer.outflow_transaction.kind rejected_transfer = RejectedTransfer .joins(inflow_transaction: :entry) @@ -1711,6 +1842,61 @@ class Family::DataImporterTest < ActiveSupport::TestCase assert_equal category.id, action.value end + test "session rule reimport only replaces current family conditions and actions" do + rule = @family.rules.build(name: "Original Rule", resource_type: "transaction", active: true) + rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "old") + rule.actions.build(action_type: "auto_categorize") + rule.save! + + other_family = Family.create!(name: "Other Rules Family", currency: "USD") + other_rule = other_family.rules.build(name: "Other Rule", resource_type: "transaction", active: true) + other_rule.conditions.build(condition_type: "transaction_name", operator: "like", value: "other-old") + other_rule.actions.build(action_type: "auto_categorize") + other_rule.save! + + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_session.source_mappings.create!( + family: other_family, + source_type: "Rule", + source_id: "rule-1", + target: other_rule + ) + + session = @family.import_sessions.create!(expected_chunks: 1) + session.source_mappings.create!( + family: @family, + source_type: "Rule", + source_id: "rule-1", + target: rule + ) + + ndjson = build_ndjson([ + { + type: "Rule", + version: 1, + data: { + id: "rule-1", + name: "Updated Rule", + resource_type: "transaction", + active: true, + conditions: [ + { condition_type: "transaction_name", operator: "like", value: "new" } + ], + actions: [ + { action_type: "set_transaction_name", value: "Renamed" } + ] + } + } + ]) + + Family::DataImporter.new(@family, ndjson, import_session: session).import! + + assert_equal [ "new" ], rule.reload.conditions.map(&:value) + assert_equal [ "set_transaction_name" ], rule.actions.map(&:action_type) + assert_equal [ "other-old" ], other_rule.reload.conditions.map(&:value) + assert_equal [ "auto_categorize" ], other_rule.actions.map(&:action_type) + end + test "imports rules from normalized operand value refs" do ndjson = build_ndjson([ { diff --git a/test/models/import_session_test.rb b/test/models/import_session_test.rb new file mode 100644 index 000000000..d1b80df4b --- /dev/null +++ b/test/models/import_session_test.rb @@ -0,0 +1,809 @@ +require "test_helper" + +class ImportSessionTest < ActiveSupport::TestCase + setup do + @family = families(:empty) + end + + test "job requires import session" do + error = assert_raises(ArgumentError) do + ImportSessionJob.perform_now(nil) + end + + assert_equal "ImportSessionJob requires an import_session", error.message + end + + test "job publishes import session" do + import_session = @family.import_sessions.create! + import_session.expects(:publish).once + + ImportSessionJob.perform_now(import_session) + end + + test "publishes ordered chunks with source mappings across files" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.complete? + account = @family.accounts.find_by!(name: "Session Checking") + entry = account.entries.find_by!(name: "Grocery Run") + transaction = entry.entryable + + assert_equal "Groceries", transaction.category.name + assert_equal "Market", transaction.merchant.name + assert_equal [ "Weekly" ], transaction.tags.map(&:name) + assert_equal "sure_import_session:#{session.id}", entry.source + assert_equal "Transaction:txn-1", entry.external_id + assert_equal 1, session.summary.dig("transactions", "created") + + assert_source_mapping session, "Account", "acct-1", account + assert_source_mapping session, "Category", "cat-1", transaction.category + assert_source_mapping session, "Merchant", "merchant-1", transaction.merchant + assert_source_mapping session, "Tag", "tag-1", transaction.tags.first + assert_source_mapping session, "Transaction", "txn-1", transaction + end + + test "publishing session chunks records readback verification for each chunk" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + entity_chunk, transaction_chunk = session.imports.ordered_by_sequence.to_a + assert_equal 1, entity_chunk.expected_record_counts["accounts"] + assert_equal 1, transaction_chunk.expected_record_counts["transactions"] + assert_includes SureImport::VERIFICATION_STATUSES, entity_chunk.readback_verification["status"] + assert_equal 1, entity_chunk.readback_verification.dig("checked_counts", "accounts") + assert_equal 1, transaction_chunk.readback_verification.dig("checked_counts", "transactions") + assert_equal 1, transaction_chunk.readback_verification.dig("actual_delta_counts", "transactions") + end + + test "publishing the same complete session does not duplicate imported transactions" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert_no_difference("Entry.count") do + session.publish + end + end + + test "republishing failed session skips complete chunks and retries failed chunks" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + complete_chunk = session.imports.find_by!(sequence: 1) + failed_chunk = session.imports.find_by!(sequence: 2) + complete_chunk.update!(status: :complete, summary: { "accounts" => { "created" => 1 } }, error_details: {}) + failed_chunk.update!(status: :failed, error: "transient failure", error_details: { "code" => "import_failed" }) + session.update!( + status: :failed, + summary: complete_chunk.summary, + error_details: { "code" => "import_failed", "message" => "transient failure" } + ) + processed_sequences = [] + + importer_factory = lambda do |_family, _content, import_session:, import:| + processed_sequences << import.sequence + flunk "completed chunk was reprocessed" if import.sequence == 1 + assert_equal session, import_session + + Object.new.tap do |importer| + importer.define_singleton_method(:import!) do + { + accounts: [], + entries: [], + summary: { "transactions" => { "created" => 1 } } + } + end + end + end + + Family::DataImporter.stub(:new, importer_factory) do + session.publish + end + + assert_equal [ 2 ], processed_sequences + assert complete_chunk.reload.complete? + assert failed_chunk.reload.complete? + assert session.reload.complete? + assert_equal 1, session.summary.dig("accounts", "created") + assert_equal 1, session.summary.dig("transactions", "created") + end + + test "publish keeps session complete and records safe error when family sync enqueue fails" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + Family.any_instance.stubs(:sync_later).raises(StandardError, "redis://secret.local/0") + session.publish + + assert session.reload.complete? + assert_equal "family_sync_enqueue_failed", session.error_details["code"] + assert_equal "Family sync could not be queued after import completion.", session.error_details["message"] + assert_no_match(/secret/, session.error_details.to_json) + end + + test "publish stores generic error details for unexpected import failures" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + importer_factory = ->(*) { raise StandardError, "redis://secret.local/0" } + + Family::DataImporter.stub(:new, importer_factory) do + session.publish + end + + assert session.reload.failed? + assert_equal "Import session failed.", session.imports.first.error + assert_equal "import_failed", session.error_details["code"] + assert_equal "Import session failed.", session.error_details["message"] + assert_no_match(/secret/, session.error_details.to_json) + end + + test "publish later requires the exact expected chunk sequences" do + session = @family.import_sessions.create!(expected_chunks: 2) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + error = assert_raises(ImportSession::ConflictError) do + session.publish_later + end + + expected_message = "import session chunks do not match expected sequences " \ + "(missing sequences: 2)" + assert_equal expected_message, error.message + assert session.reload.pending? + end + + test "chunk upload rejects sequences beyond the expected chunk count" do + session = @family.import_sessions.create!(expected_chunks: 1) + + error = assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 2, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + end + + assert_equal "sequence exceeds expected_chunks", error.message + assert_empty session.imports + end + + test "publish later restores status and records enqueue failures" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + ImportSessionJob.stub(:perform_later, ->(_import_session) { raise StandardError, "queue offline" }) do + error = assert_raises(ImportSession::EnqueueError) do + session.publish_later + end + + assert_equal "Import session could not be queued.", error.message + end + + assert session.reload.pending? + assert_equal "import_enqueue_failed", session.error_details["code"] + assert_equal "Import session could not be queued.", session.error_details["message"] + end + + test "publish later syncs chunk row counts before enforcing row limit" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records + transaction_records), + filename: "session.ndjson", + content_type: "application/x-ndjson" + ) + session.imports.update_all(rows_count: 0) + + SureImport.stub(:max_row_count, 1) do + assert_raises(Import::MaxRowCountExceededError) { session.publish_later } + end + + assert session.reload.pending? + assert_equal 5, session.imports.reload.first.rows_count + end + + test "fails loudly when a later chunk references a missing source id" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + chunk = session.imports.first + assert chunk.failed? + assert_equal "missing_source_reference", chunk.error_details["code"] + assert_equal "acct-1", chunk.error_details["source_id"] + assert_equal 0, @family.entries.count + end + + test "source mappings from another family cannot satisfy missing references" do + other_family = Family.create!(name: "Other Family", currency: "USD", locale: "en") + other_session = other_family.import_sessions.create!(expected_chunks: 1) + other_session.attach_chunk!( + sequence: 1, + content: build_ndjson(entity_records), + filename: "other-entities.ndjson", + content_type: "application/x-ndjson" + ) + other_session.publish + + assert other_session.reload.complete? + assert_equal 1, other_session.source_mappings.where(source_type: "Account", source_id: "acct-1").count + + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal "missing_source_reference", session.imports.first.error_details["code"] + assert_equal "acct-1", session.imports.first.error_details["source_id"] + assert_equal 0, @family.entries.count + end + + test "session mode rejects invalid account accountable types" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson([ + { + type: "Account", + data: { + id: "acct-invalid", + name: "Invalid Account", + balance: "100.00", + currency: "USD", + accountable_type: "Kernel" + } + } + ]), + filename: "accounts.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal 0, @family.accounts.count + assert_equal "invalid_import_record", session.imports.first.error_details["code"] + assert_equal "Account", session.imports.first.error_details["record_type"] + assert_equal "accountable_type", session.imports.first.error_details["field"] + assert_equal "Kernel", session.imports.first.error_details["value"] + end + + test "chunk upload is idempotent by sequence and checksum" do + session = @family.import_sessions.create! + content = build_ndjson(entity_records) + + first = session.attach_chunk!( + sequence: 1, + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + second = session.attach_chunk!( + sequence: 1, + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal first.id, second.id + assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 1, + content: build_ndjson(transaction_records), + filename: "different.ndjson", + content_type: "application/x-ndjson" + ) + end + end + + test "chunk upload repairs incomplete existing chunk before accepting retry" do + session = @family.import_sessions.create! + content = build_ndjson(transaction_records) + chunk = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + + result = session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal chunk.id, result.id + assert result.reload.ndjson_file.attached? + assert_equal 1, result.rows_count + end + + test "chunk upload resyncs attached existing chunk before accepting retry" do + session = @family.import_sessions.create! + content = build_ndjson(transaction_records) + chunk = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + chunk.ndjson_file.attach( + io: StringIO.new(content), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + result = session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + + assert_equal chunk.id, result.id + assert_equal 1, result.rows_count + end + + test "chunk upload rejects inconsistent sequence and client chunk keys" do + session = @family.import_sessions.create! + session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: build_ndjson(entity_records), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + session.attach_chunk!( + sequence: 2, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + + error = assert_raises(ImportSession::ConflictError) do + session.attach_chunk!( + sequence: 1, + client_chunk_id: "transactions", + content: build_ndjson(transaction_records), + filename: "transactions.ndjson", + content_type: "application/x-ndjson" + ) + end + + assert_equal "sequence and client_chunk_id refer to different chunks", error.message + end + + test "chunk upload treats duplicate insert races as idempotent retries" do + session = @family.import_sessions.create! + content = build_ndjson(entity_records) + existing = session.imports.create!( + family: @family, + type: "SureImport", + sequence: 1, + client_chunk_id: "entities", + checksum: Digest::SHA256.hexdigest(content) + ) + existing.ndjson_file.attach( + io: StringIO.new(content), + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + existing.sync_ndjson_rows_count! + lookup_count = 0 + + session.stub(:existing_chunk_for!, ->(**) { + lookup_count += 1 + lookup_count == 1 ? nil : existing + }) do + session.stub(:create_chunk!, ->(**) { raise ActiveRecord::RecordNotUnique, "duplicate chunk" }) do + assert_equal existing, session.attach_chunk!( + sequence: 1, + client_chunk_id: "entities", + content: content, + filename: "entities.ndjson", + content_type: "application/x-ndjson" + ) + end + end + + assert_equal 2, lookup_count + end + + test "client session creation treats duplicate insert races as idempotent retries" do + existing = @family.import_sessions.create!(client_session_id: "race-session", expected_chunks: 2) + ImportSession.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + session = ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 2 + ) + + assert_equal existing, session + end + + test "client session creation race backfills missing expected chunks" do + existing = @family.import_sessions.create!(client_session_id: "race-session") + racing_session = @family.import_sessions.build(client_session_id: "race-session") + racing_session.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + @family.import_sessions.stub(:find_or_initialize_by, racing_session) do + session = ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 2 + ) + + assert_equal existing, session + end + assert_equal 2, existing.reload.expected_chunks + end + + test "client session creation race preserves expected chunks conflict" do + @family.import_sessions.create!(client_session_id: "race-session", expected_chunks: 2) + ImportSession.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique) + + error = assert_raises(ImportSession::ConflictError) do + ImportSession.create_or_find_for!( + family: @family, + import_type: "SureImport", + client_session_id: "race-session", + expected_chunks: 3 + ) + end + + assert_equal "client_session_id already exists with a different expected_chunks value", error.message + end + + test "session mode rejects rule records without source ids" do + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: build_ndjson([ + { + type: "Rule", + data: { + name: "Missing Source Rule", + resource_type: "transaction", + active: true + } + } + ]), + filename: "rules.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.failed? + assert_equal 0, @family.rules.count + assert_equal "missing_source_reference", session.imports.first.error_details["code"] + assert_equal "Rule", session.imports.first.error_details["record_type"] + assert_equal "(blank)", session.imports.first.error_details["source_id"] + end + + test "session mode imports rule records exported by Sure packages" do + source_family = Family.create!(name: "Rule Export Source", currency: "USD", locale: "en") + category = source_family.categories.create!( + name: "Exported Category", + color: "#00AA00", + lucide_icon: "shapes" + ) + source_rule = source_family.rules.build( + name: "Exported Rule", + resource_type: "transaction", + active: true + ) + source_rule.conditions.build( + condition_type: "transaction_name", + operator: "like", + value: "Coffee" + ) + source_rule.actions.build( + action_type: "set_transaction_category", + value: category.id + ) + source_rule.save! + + session = @family.import_sessions.create!(expected_chunks: 1) + session.attach_chunk!( + sequence: 1, + content: exported_ndjson_for(source_family), + filename: "all.ndjson", + content_type: "application/x-ndjson" + ) + + session.publish + + assert session.reload.complete? + imported_rule = @family.rules.find_by!(name: "Exported Rule") + imported_category = @family.categories.find_by!(name: "Exported Category") + + assert_equal 1, session.summary.dig("rules", "created") + assert_equal imported_category.id, imported_rule.actions.first.value + assert_source_mapping session, "Rule", source_rule.id, imported_rule + end + + test "client idempotency keys are bounded before indexed writes" do + session = @family.import_sessions.build(client_session_id: "x" * 256) + + assert_not session.valid? + assert_includes session.errors[:client_session_id], "is too long (maximum is 255 characters)" + + import = @family.imports.build(type: "SureImport", client_chunk_id: "x" * 256) + + assert_not import.valid? + assert_includes import.errors[:client_chunk_id], "is too long (maximum is 255 characters)" + + import.sequence = 0 + import.checksum = "short" + + assert_not import.valid? + assert_includes import.errors[:sequence], "must be greater than 0" + assert_includes import.errors[:checksum], "is the wrong length (should be 64 characters)" + + other_family = Family.create!(name: "Other Import Family", currency: "USD", locale: "en") + import.import_session = other_family.import_sessions.build + import.sequence = nil + import.checksum = nil + + assert_not import.valid? + assert_includes import.errors[:import_session], "must belong to your family" + assert_includes import.errors[:sequence], "must be present for import session chunks" + assert_includes import.errors[:checksum], "must be present for import session chunks" + + mapping = @family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: "x" * 65, + source_id: "x" * 256, + target_type: "Account", + target_id: SecureRandom.uuid + ) + + assert_not mapping.valid? + assert_includes mapping.errors[:source_type], "is too long (maximum is 64 characters)" + assert_includes mapping.errors[:source_id], "is too long (maximum is 255 characters)" + + mapping.source_type = "Unsupported" + mapping.source_id = "acct-1" + + assert_not mapping.valid? + assert_includes mapping.errors[:source_type], "is not included in the list" + + mapping.source_type = "Account" + mapping.target_type = "Unsupported" + + assert_not mapping.valid? + assert_includes mapping.errors[:target_type], "is not included in the list" + end + + test "client idempotency keys are stripped before validation" do + session = @family.import_sessions.create!(client_session_id: " session-1 ") + import = @family.imports.create!(type: "SureImport", client_chunk_id: " chunk-1 ") + category = @family.categories.create!(name: "Mapping Category") + mapping = session.source_mappings.create!( + family: @family, + source_type: "Category", + source_id: " cat-1 ", + target: category + ) + + assert_equal "session-1", session.client_session_id + assert_equal "chunk-1", import.client_chunk_id + assert_equal "cat-1", mapping.source_id + end + + test "session status payloads must remain JSON objects" do + session = @family.import_sessions.build(summary: [], error_details: "failed") + import = @family.imports.build(type: "SureImport", summary: [], error_details: "failed") + + assert_not session.valid? + assert_includes session.errors[:summary], "must be an object" + assert_includes session.errors[:error_details], "must be an object" + + assert_not import.valid? + assert_includes import.errors[:summary], "must be an object" + assert_includes import.errors[:error_details], "must be an object" + end + + test "source mappings must belong to the same family as their import session" do + other_family = Family.create!(name: "Other Mapping Family", currency: "USD", locale: "en") + mapping = other_family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: "Account", + source_id: "acct-1", + target: @family.accounts.build(name: "Session Checking") + ) + + assert_not mapping.valid? + assert_includes mapping.errors[:family], "must match import session" + end + + test "source mapping targets must not cross family boundaries" do + other_family = Family.create!(name: "Other Mapping Target Family", currency: "USD", locale: "en") + mapping = @family.import_source_mappings.build( + import_session: @family.import_sessions.build, + source_type: " Account ", + source_id: "acct-1", + target: other_family.accounts.build(name: "Other Checking") + ) + + assert_not mapping.valid? + assert_equal "Account", mapping.source_type + assert_includes mapping.errors[:target], "must belong to your family" + end + + private + def entity_records + [ + { + type: "Account", + data: { + id: "acct-1", + name: "Session Checking", + balance: "100.00", + currency: "USD", + accountable_type: "Depository", + accountable: { subtype: "checking" } + } + }, + { + type: "Category", + data: { + id: "cat-1", + name: "Groceries", + color: "#407706", + classification: "expense" + } + }, + { + type: "Merchant", + data: { + id: "merchant-1", + name: "Market", + color: "#111111" + } + }, + { + type: "Tag", + data: { + id: "tag-1", + name: "Weekly", + color: "#222222" + } + } + ] + end + + def transaction_records + [ + { + type: "Transaction", + data: { + id: "txn-1", + account_id: "acct-1", + category_id: "cat-1", + merchant_id: "merchant-1", + tag_ids: [ "tag-1" ], + date: "2024-01-15", + amount: "-12.34", + currency: "USD", + name: "Grocery Run" + } + } + ] + end + + def build_ndjson(records) + records.map(&:to_json).join("\n") + end + + def exported_ndjson_for(family) + ndjson = nil + + Zip::File.open_buffer(Family::DataExporter.new(family).generate_export) do |zip| + ndjson = zip.read("all.ndjson") + end + + ndjson + end + + def assert_source_mapping(session, source_type, source_id, target) + mapping = session.source_mappings.find_by!(source_type: source_type, source_id: source_id) + + assert_equal @family, mapping.family + assert_equal target, mapping.target + end +end diff --git a/test/models/period_test.rb b/test/models/period_test.rb index fa1221eb9..33e01bb02 100644 --- a/test/models/period_test.rb +++ b/test/models/period_test.rb @@ -63,6 +63,29 @@ class PeriodTest < ActiveSupport::TestCase assert_equal Date.current, period.end_date end + test "current_month_for preserves the current_month key for custom-month families" do + family = mock("family") + family.stubs(:uses_custom_month_start?).returns(true) + family.stubs(:current_custom_month_period).returns( + Period.custom(start_date: Date.new(2026, 6, 5), end_date: Date.new(2026, 7, 4)) + ) + + period = Period.current_month_for(family) + assert_equal "current_month", period.key + assert_equal Date.new(2026, 6, 5), period.start_date + assert_equal Date.new(2026, 7, 4), period.end_date + end + + test "last_month_for preserves the last_month key for custom-month families" do + family = mock("family") + family.stubs(:uses_custom_month_start?).returns(true) + family.stubs(:custom_month_start_for).returns(Date.new(2026, 5, 5)) + family.stubs(:custom_month_end_for).returns(Date.new(2026, 6, 4)) + + period = Period.last_month_for(family) + assert_equal "last_month", period.key + end + test "all_time period uses fallback when no family or entries exist" do Current.expects(:family).returns(nil) diff --git a/test/models/provider/anthropic/bank_statement_extractor_test.rb b/test/models/provider/anthropic/bank_statement_extractor_test.rb new file mode 100644 index 000000000..203246221 --- /dev/null +++ b/test/models/provider/anthropic/bank_statement_extractor_test.rb @@ -0,0 +1,141 @@ +require "test_helper" +require "ostruct" + +class Provider::Anthropic::BankStatementExtractorTest < ActiveSupport::TestCase + setup do + @pdf_content = "%PDF-1.4 fake bytes".b + end + + test "sends PDF as native document and returns normalized transactions + metadata" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_1", + name: "report_bank_statement", + input: { + "bank_name" => "Bank of Example", + "account_holder" => "Jane Doe", + "account_number" => "1234", + "statement_period" => { "start_date" => "2026-03-01", "end_date" => "2026-03-31" }, + "opening_balance" => 1000.0, + "closing_balance" => 1500.0, + "transactions" => [ + { "date" => "2026-03-05", "description" => "Coffee", "amount" => -4.5 }, + { "date" => "2026-03-15", "description" => "Salary", "amount" => 3000.0, "reference" => "Payroll Mar" } + ] + } + ) + ]) + client = stub_client(fake_response) + + result = Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + + assert_equal "Bank of Example", result[:bank_name] + assert_equal "Jane Doe", result[:account_holder] + assert_equal "1234", result[:account_number] + assert_equal "2026-03-01", result[:period][:start_date] + assert_equal "2026-03-31", result[:period][:end_date] + assert_equal 1000.0, result[:opening_balance] + assert_equal 1500.0, result[:closing_balance] + + assert_equal 2, result[:transactions].size + txn1 = result[:transactions].first + assert_equal "2026-03-05", txn1[:date] + assert_equal "Coffee", txn1[:name] + assert_equal(-4.5, txn1[:amount]) + + txn2 = result[:transactions].last + assert_equal "Salary", txn2[:name] + assert_equal 3000.0, txn2[:amount] + assert_equal "Payroll Mar", txn2[:notes] + end + + test "raises when pdf_content is blank" do + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: mock, + model: "claude-sonnet-4-6", + pdf_content: nil + ).extract + end + assert_match(/PDF content is required/i, err.message) + end + + test "raises when model omits the tool call" do + fake_response = build_response(content: [ OpenStruct.new(type: :text, text: "no tool") ]) + client = stub_client(fake_response) + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + end + assert_match(/did not invoke report_bank_statement/i, err.message) + end + + test "raises before API call when pdf_content exceeds the 32 MB limit" do + oversized = "a".b * (Provider::Anthropic::BankStatementExtractor::MAX_PDF_BYTES + 1) + client = mock + client.expects(:messages).never + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: oversized + ).extract + end + assert_match(/exceeds Anthropic's 32 MB limit/i, err.message) + end + + test "flags result as truncated when stop_reason is max_tokens" do + fake_response = build_response( + content: [ + tool_use_block( + id: "toolu_1", + name: "report_bank_statement", + input: { "transactions" => [ { "date" => "2026-03-05", "description" => "Coffee", "amount" => -4.5 } ] } + ) + ] + ) + fake_response.stop_reason = :max_tokens + client = stub_client(fake_response) + + Rails.logger.expects(:warn).with(regexp_matches(/truncated by max_tokens/i)) + + result = Provider::Anthropic::BankStatementExtractor.new( + client: client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).extract + + assert_equal true, result[:truncated] + end + + private + def stub_client(response) + messages = mock + messages.stubs(:create).returns(response) + client = mock + client.stubs(:messages).returns(messages) + client + end + + def build_response(content:, usage: { input_tokens: 1500, output_tokens: 400 }) + OpenStruct.new( + id: "msg_test", + model: "claude-sonnet-4-6", + content: content, + usage: OpenStruct.new(input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]) + ) + end + + def tool_use_block(id:, name:, input:) + OpenStruct.new(type: :tool_use, id: id, name: name, input: input) + end +end diff --git a/test/models/provider/anthropic/pdf_processor_test.rb b/test/models/provider/anthropic/pdf_processor_test.rb new file mode 100644 index 000000000..d2cdbb3a7 --- /dev/null +++ b/test/models/provider/anthropic/pdf_processor_test.rb @@ -0,0 +1,126 @@ +require "test_helper" +require "ostruct" + +class Provider::Anthropic::PdfProcessorTest < ActiveSupport::TestCase + setup do + @pdf_content = "%PDF-1.4 fake bytes".b + end + + test "sends PDF as native document content block and parses tool response" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_1", + name: "report_document_analysis", + input: { + "document_type" => "bank_statement", + "summary" => "Bank of Example, Mar 2026 statement.", + "extracted_data" => { + "institution_name" => "Bank of Example", + "statement_period_start" => "2026-03-01", + "statement_period_end" => "2026-03-31", + "transaction_count" => 42, + "opening_balance" => 1000.0, + "closing_balance" => 1500.0, + "currency" => "USD", + "account_holder" => "Account Holder" + } + } + ) + ]) + captured = nil + client = stub_client(fake_response) { |params| captured = params } + + result = Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).process + + document_block = captured[:messages].first[:content].first + assert_equal "document", document_block[:type] + assert_equal "application/pdf", document_block[:source][:media_type] + assert_equal "base64", document_block[:source][:type] + assert_equal Base64.strict_encode64(@pdf_content), document_block[:source][:data] + + assert_equal "report_document_analysis", captured[:tool_choice][:name] + assert captured[:tool_choice][:disable_parallel_tool_use] + + assert_equal "bank_statement", result.document_type + assert_equal "Bank of Example, Mar 2026 statement.", result.summary + assert_equal 42, result.extracted_data["transaction_count"] + end + + test "normalizes unknown document_type to other" do + fake_response = build_response(content: [ + tool_use_block( + id: "toolu_2", + name: "report_document_analysis", + input: { + "document_type" => "alien_invasion_form", + "summary" => "Unknown.", + "extracted_data" => {} + } + ) + ]) + client = stub_client(fake_response) + + result = Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: @pdf_content + ).process + + assert_equal "other", result.document_type + end + + test "raises when pdf_content is blank" do + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::PdfProcessor.new( + mock, + model: "claude-sonnet-4-6", + pdf_content: "" + ).process + end + assert_match(/PDF content is required/i, err.message) + end + + test "raises before any API call when pdf_content exceeds the base64-adjusted cap" do + oversized = "a".b * (Provider::Anthropic::PdfProcessor::MAX_PDF_BYTES + 1) + client = mock + client.expects(:messages).never + + err = assert_raises(Provider::Anthropic::Error) do + Provider::Anthropic::PdfProcessor.new( + client, + model: "claude-sonnet-4-6", + pdf_content: oversized + ).process + end + assert_match(/32 MB request limit/i, err.message) + end + + private + def stub_client(response) + messages = mock + messages.expects(:create).with do |params| + yield(params) if block_given? + true + end.returns(response) + client = mock + client.stubs(:messages).returns(messages) + client + end + + def build_response(content:, usage: { input_tokens: 800, output_tokens: 200 }) + OpenStruct.new( + id: "msg_test", + model: "claude-sonnet-4-6", + content: content, + usage: OpenStruct.new(input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens]) + ) + end + + def tool_use_block(id:, name:, input:) + OpenStruct.new(type: :tool_use, id: id, name: name, input: input) + end +end diff --git a/test/models/provider/enable_banking_test.rb b/test/models/provider/enable_banking_test.rb index e09c90c7e..16c3a8d8a 100644 --- a/test/models/provider/enable_banking_test.rb +++ b/test/models/provider/enable_banking_test.rb @@ -61,4 +61,47 @@ class Provider::EnableBankingTest < ActiveSupport::TestCase assert_equal Date.new(2026, 1, 17), error.corrected_date_from assert error.wrong_transactions_period? end + + test "start_authorization includes auth_method in the request body when provided" do + captured_body = nil + response = OpenStruct.new( + code: 200, + body: { url: "https://api.enablebanking.com/auth/abc", authorization_id: "auth_1" }.to_json + ) + + Provider::EnableBanking.expects(:post).with do |_url, options| + captured_body = JSON.parse(options[:body]) + true + end.returns(response) + + @provider.start_authorization( + aspsp_name: "VR Bank in Holstein", + aspsp_country: "DE", + redirect_url: "https://app.example.com/callback", + auth_method: "decoupled_app" + ) + + assert_equal "decoupled_app", captured_body["auth_method"] + end + + test "start_authorization omits auth_method when not provided" do + captured_body = nil + response = OpenStruct.new( + code: 200, + body: { url: "https://api.enablebanking.com/auth/abc", authorization_id: "auth_1" }.to_json + ) + + Provider::EnableBanking.expects(:post).with do |_url, options| + captured_body = JSON.parse(options[:body]) + true + end.returns(response) + + @provider.start_authorization( + aspsp_name: "ING-DiBa AG", + aspsp_country: "DE", + redirect_url: "https://app.example.com/callback" + ) + + assert_not captured_body.key?("auth_method") + end end