diff --git a/app/controllers/account_statements_controller.rb b/app/controllers/account_statements_controller.rb index 27793c4b6..3c3bd992a 100644 --- a/app/controllers/account_statements_controller.rb +++ b/app/controllers/account_statements_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class AccountStatementsController < ApplicationController - before_action :set_statement, only: %i[show update destroy link unlink reject] - before_action :ensure_statement_manager!, only: %i[index create update destroy link unlink reject] + before_action :set_statement, only: %i[show update destroy extract link unlink reject] + before_action :ensure_statement_manager!, only: %i[index create update destroy extract link unlink reject] def index accessible_account_ids = Current.user.accessible_accounts.select(:id) @@ -30,6 +30,7 @@ def show @accounts = Current.user.accessible_accounts.visible.alphabetically @can_manage_statement = @statement.manageable_by?(Current.user) @reconciliation_checks = @statement.reconciliation_checks + @latest_pdf_import = @statement.latest_reusable_pdf_import if @statement.pdf? @breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("account_statements.index.title"), account_statements_path ], @@ -41,6 +42,7 @@ def show def create files = Array(statement_upload_params[:files]).reject(&:blank?).select { |file| file.respond_to?(:read) } account = target_account + return if performed? if files.empty? redirect_back_or_to account_statements_path, alert: t("account_statements.create.no_files") @@ -56,22 +58,24 @@ def create files.each do |file| prepared_upload = AccountStatement.prepare_upload!(file) created << AccountStatement.create_from_prepared_upload!(family: Current.family, account: account, prepared_upload: prepared_upload) - rescue AccountStatement::InvalidUploadError - validation_errors << t("account_statements.create.invalid_file_type") + rescue AccountStatement::InvalidUploadError => e + validation_errors << invalid_upload_message(e) rescue AccountStatement::DuplicateUploadError => e duplicates << e.statement rescue ActiveRecord::RecordInvalid => e validation_errors << e.record.errors.full_messages.to_sentence end - redirect_to redirect_after_create(account, created.first || duplicates.first), + redirect_statement = created.first || duplicates.find { |statement| statement.viewable_by?(Current.user) } + redirect_to redirect_after_create(account, redirect_statement), flash_for_upload(created:, duplicates:, validation_errors:) end def update return if @statement.account && !require_account_permission!(@statement.account) - target = statement_account_id.present? ? Current.user.accessible_accounts.find(statement_account_id) : nil + target = statement_account_id.present? ? target_account : nil + return if performed? return if target && !require_account_permission!(target) attrs = statement_params.to_h @@ -100,7 +104,11 @@ def link return end - account = Current.user.accessible_accounts.find(account_id) + account = Current.user.accessible_accounts.find_by(id: account_id) + unless account + redirect_to account_statement_path(@statement), alert: t("accounts.not_authorized") + return + end return unless require_account_permission!(account) @statement.link_to_account!(account) @@ -116,6 +124,7 @@ def unlink def reject return if @statement.account && !require_account_permission!(@statement.account) + return redirect_to(account_statement_path(@statement), alert: t("account_statements.reject.linked")) if @statement.account.present? @statement.reject_match! redirect_to account_statements_path, notice: t("account_statements.reject.success") @@ -132,6 +141,23 @@ def destroy end end + def extract + unless @statement.manageable_by?(Current.user) + redirect_to account_statement_path(@statement), alert: t("accounts.not_authorized") + return + end + + unless @statement.pdf? + redirect_to account_statement_path(@statement), alert: t("account_statements.extract.not_pdf") + return + end + + pdf_import = PdfImport.create_from_statement!(statement: @statement) + pdf_import.process_with_ai_later + + redirect_to import_path(pdf_import), notice: t("account_statements.extract.started") + end + private def set_statement @@ -146,7 +172,7 @@ def set_statement def ensure_statement_manager! return if AccountStatement.statement_manager?(Current.user) - redirect_to accounts_path, alert: t("accounts.not_authorized") + redirect_back_or_to accounts_path, alert: t("accounts.not_authorized") end def statement_upload_params @@ -170,7 +196,11 @@ def target_account account_id = statement_account_id.presence return nil if account_id.blank? - Current.user.accessible_accounts.find(account_id) + account = Current.user.accessible_accounts.find_by(id: account_id) + return account if account + + redirect_back_or_to account_statements_path, alert: t("accounts.not_authorized") + nil end def statement_account_id @@ -191,6 +221,14 @@ def redirect_after_create(account, statement = nil) end end + def invalid_upload_message(error) + reason = error.respond_to?(:reason) ? error.reason : nil + key = reason.in?(%i[oversize unsupported_type empty invalid_pdf_header]) ? reason : :invalid_file_type + + t("account_statements.create.upload_errors.#{key}", + default: t("account_statements.create.invalid_file_type")) + end + def post_link_path(statement) statement.account ? account_path(statement.account, tab: "statements") : account_statement_path(statement) end diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 4a3cc0492..8ba5a2e06 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -2,9 +2,9 @@ class ImportsController < ApplicationController include SettingsHelper before_action :set_import, only: %i[show update publish destroy revert apply_template] + before_action :require_statement_import_permission!, only: %i[update publish destroy revert apply_template] def update - # Handle both pdf_import[account_id] and import[account_id] param formats account_id = params.dig(:pdf_import, :account_id) || params.dig(:import, :account_id) if account_id.present? @@ -13,7 +13,9 @@ def update redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.") return end - @import.update!(account: account) + return if @import.account_statement.present? && !require_account_permission!(account) + + @import.assign_account!(account) end redirect_to import_path(@import), notice: t("imports.update.account_saved", default: "Account saved.") @@ -54,12 +56,7 @@ def create return end - # Handle PDF file uploads - process with AI if file.present? && Import::ALLOWED_PDF_MIME_TYPES.include?(file.content_type) - unless valid_pdf_file?(file) - redirect_to new_import_path, alert: t("imports.create.invalid_pdf") - return - end create_pdf_import(file) return end @@ -87,8 +84,6 @@ def create return end - # Stream reading is not fully applicable here as we store the raw string in the DB, - # but we have validated size beforehand to prevent memory exhaustion from massive files. import.update!(raw_file_str: file.read) redirect_to import_configuration_path(import), notice: t("imports.create.csv_uploaded") @@ -132,24 +127,41 @@ def destroy private def set_import - @import = Current.family.imports.includes(:account).find(params[:id]) + @import = Current.family.imports.includes(:account, :account_statement).find(params[:id]) + raise ActiveRecord::RecordNotFound if @import.account_statement.present? && !@import.account_statement.viewable_by?(Current.user) end def import_params params.require(:import).permit(:import_file) end + def require_statement_import_permission! + return unless @import.account_statement.present? + return if @import.account_statement.manageable_by?(Current.user) + + redirect_target = @import.account || @import.account_statement + redirect_back_or_to redirect_target, alert: t("accounts.not_authorized") + end + def create_pdf_import(file) + unless AccountStatement.statement_manager?(Current.user) + redirect_to new_import_path, alert: t("accounts.not_authorized") + return + end + if file.size > Import::MAX_PDF_SIZE redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte) return end - pdf_import = Current.family.imports.create!(type: "PdfImport") - pdf_import.pdf_file.attach(file) + pdf_import = PdfImport.create_from_upload!(family: Current.family, file: file, user: Current.user) pdf_import.process_with_ai_later redirect_to import_path(pdf_import), notice: t("imports.create.pdf_processing") + rescue AccountStatement::DuplicateUploadError + redirect_to new_import_path, alert: t("imports.create.duplicate_pdf_unavailable") + rescue AccountStatement::InvalidUploadError + redirect_to new_import_path, alert: t("imports.create.invalid_pdf") end def create_document_import(file) @@ -174,11 +186,6 @@ def create_document_import(file) end if ext == ".pdf" - unless valid_pdf_file?(file) - redirect_to new_import_path, alert: t("imports.create.invalid_pdf") - return - end - create_pdf_import(file) return end @@ -239,10 +246,4 @@ def create_sure_import(file) redirect_to import_path(import), notice: t("imports.create.ndjson_uploaded") end - - def valid_pdf_file?(file) - header = file.read(5) - file.rewind - header&.start_with?("%PDF-") - end end diff --git a/app/jobs/process_pdf_job.rb b/app/jobs/process_pdf_job.rb index 1ed48cf49..0145b1541 100644 --- a/app/jobs/process_pdf_job.rb +++ b/app/jobs/process_pdf_job.rb @@ -1,11 +1,13 @@ class ProcessPdfJob < ApplicationJob + PROCESSING_CLAIM_TTL = 30.minutes + queue_as :medium_priority def perform(pdf_import) return unless pdf_import.is_a?(PdfImport) - return unless pdf_import.pdf_uploaded? + return reset_processing_claim(pdf_import) unless pdf_import.pdf_uploaded? return if pdf_import.status == "complete" - return if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0) + return reset_processing_claim(pdf_import) if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0) pdf_import.update!(status: :importing) @@ -62,7 +64,7 @@ def sanitize_error_message(error) end def upload_to_vector_store(pdf_import, document_type:) - filename = pdf_import.pdf_file.filename.to_s + filename = pdf_import.pdf_filename file_content = pdf_import.pdf_file_content family_document = pdf_import.family.upload_document( @@ -85,4 +87,16 @@ def resolve_document_type(pdf_import, process_result) def statement_with_transactions?(document_type) document_type.in?(%w[bank_statement credit_card_statement]) end + + def reset_processing_claim(pdf_import) + pdf_import.with_lock do + if pdf_import.importing? && processing_claim_stale?(pdf_import) + pdf_import.update!(status: :pending) + end + end + end + + def processing_claim_stale?(pdf_import) + pdf_import.updated_at <= PROCESSING_CLAIM_TTL.ago + end end diff --git a/app/models/account_statement.rb b/app/models/account_statement.rb index 07cc80c86..4befd4664 100644 --- a/app/models/account_statement.rb +++ b/app/models/account_statement.rb @@ -15,7 +15,15 @@ def initialize(statement) super("Statement file has already been uploaded") end end - InvalidUploadError = Class.new(StandardError) + InvalidUploadError = Class.new(StandardError) do + attr_reader :reason + + def initialize(reason = :invalid_file_type) + @reason = reason + super("Invalid statement upload: #{reason}") + end + end + LinkedStatementRejectionError = Class.new(StandardError) PreparedUpload = Data.define(:content, :filename, :content_type, :byte_size, :checksum, :content_sha256) @@ -33,6 +41,7 @@ def initialize(statement) belongs_to :account, optional: true belongs_to :suggested_account, class_name: "Account", optional: true + has_many :pdf_imports, -> { where(type: "PdfImport").ordered }, class_name: "PdfImport", dependent: :nullify has_one_attached :original_file, dependent: :purge_later enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload" @@ -82,6 +91,7 @@ def create_from_upload!(family:, account:, file:) def create_from_prepared_upload!(family:, account:, prepared_upload:) statement = nil + saved = false duplicate = duplicate_for(family, prepared_upload) raise DuplicateUploadError, duplicate if duplicate @@ -107,19 +117,15 @@ def create_from_prepared_upload!(family:, account:, prepared_upload:) MetadataDetector.new(statement, content: prepared_upload.content).apply statement.assign_account_match unless account.present? statement.save! + saved = true statement rescue ActiveRecord::RecordNotUnique duplicate = duplicate_for(family, prepared_upload) - purge_original_file(statement) - - if duplicate - raise DuplicateUploadError, duplicate - end + raise DuplicateUploadError, duplicate if duplicate raise - rescue StandardError - purge_original_file(statement) - raise + ensure + purge_original_file(statement) if statement && !saved end def reconciliation_statuses_for(statements, account:) @@ -135,11 +141,11 @@ def prepare_upload!(file) filename = file.original_filename.to_s content = read_upload_content!(file) byte_size = content.bytesize - raise InvalidUploadError if byte_size.zero? + raise InvalidUploadError, :empty if byte_size.zero? content_type = detected_content_type(content:, filename:, declared_content_type: file.content_type) - raise InvalidUploadError unless allowed_upload?(filename:, content_type:) - raise InvalidUploadError if content_type == "application/pdf" && !valid_pdf_content?(content) + raise InvalidUploadError, :unsupported_type unless allowed_upload?(filename:, content_type:) + raise InvalidUploadError, :invalid_pdf_header if content_type == "application/pdf" && !valid_pdf_content?(content) PreparedUpload.new( content: content, @@ -194,7 +200,7 @@ def balance_lookup_for(account, statements) def read_upload_content!(file) declared_size = declared_upload_size(file) - raise InvalidUploadError if declared_size.present? && declared_size > MAX_FILE_SIZE + raise InvalidUploadError, :oversize if declared_size.present? && declared_size > MAX_FILE_SIZE content = +"".b loop do @@ -202,7 +208,7 @@ def read_upload_content!(file) break if chunk.nil? || chunk.empty? content << chunk - raise InvalidUploadError if content.bytesize > MAX_FILE_SIZE + raise InvalidUploadError, :oversize if content.bytesize > MAX_FILE_SIZE end file.rewind if file.respond_to?(:rewind) @@ -254,7 +260,7 @@ def link_to_account!(target_account, confidence: 1.0) def unlink! transaction do - update!( + assign_attributes( account: nil, review_status: :unmatched, match_confidence: nil @@ -265,6 +271,8 @@ def unlink! end def reject_match! + raise LinkedStatementRejectionError, "Linked statements cannot reject a suggested match" if account.present? + update!( suggested_account: nil, match_confidence: nil, @@ -360,6 +368,10 @@ def xlsx? content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".xlsx"]) end + def latest_reusable_pdf_import + pdf_imports.where.not(status: :failed).order(created_at: :desc).first + end + private def reconciliation_check(key:, statement_amount:, ledger_amount:) @@ -388,7 +400,7 @@ def sync_file_metadata end def normalize_currency - self.currency = currency.to_s.upcase.presence if currency.present? + self.currency = currency.to_s.strip.upcase.presence if currency.present? end def sync_review_status diff --git a/app/models/account_statement/account_matcher.rb b/app/models/account_statement/account_matcher.rb index 6e8c5c1c2..6ce84c893 100644 --- a/app/models/account_statement/account_matcher.rb +++ b/app/models/account_statement/account_matcher.rb @@ -10,7 +10,7 @@ def initialize(statement) end def best_match - candidates = statement.family.accounts.visible.to_a.filter_map do |account| + candidates = statement.family.accounts.visible.includes(:account_providers).to_a.filter_map do |account| confidence = confidence_for(account) next if confidence < 0.35 diff --git a/app/models/account_statement/coverage.rb b/app/models/account_statement/coverage.rb index 225df183c..ef4c251e4 100644 --- a/app/models/account_statement/coverage.rb +++ b/app/models/account_statement/coverage.rb @@ -70,8 +70,8 @@ def default_expected_start_month(account, fallback_end_month: default_expected_e candidates = [ account.entries.minimum(:date), account.balances.minimum(:date), - account.account_statements.where.not(period_start_on: nil).minimum(:period_start_on), - account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).minimum(:period_start_on) + account.account_statements.where.not(period_start_on: nil).where.not(period_end_on: nil).minimum(:period_start_on), + account.family.account_statements.unmatched.where(suggested_account: account).where.not(period_start_on: nil).where.not(period_end_on: nil).minimum(:period_start_on) ].compact start_month = (candidates.min || fallback_end_month.advance(months: -11)).to_date.beginning_of_month diff --git a/app/models/account_statement/metadata_detector.rb b/app/models/account_statement/metadata_detector.rb index e8079d5b8..05c488b8e 100644 --- a/app/models/account_statement/metadata_detector.rb +++ b/app/models/account_statement/metadata_detector.rb @@ -115,7 +115,6 @@ def detect_from_filename if (meaningful_hint = meaningful_filename_hint(hint)) statement.institution_name_hint ||= meaningful_hint - statement.account_name_hint ||= meaningful_hint detected = true end diff --git a/app/models/import.rb b/app/models/import.rb index 2f1256ff4..fd1b63ce3 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -29,10 +29,15 @@ def self.max_csv_size MAX_CSV_SIZE end + def assign_account!(account) + update!(account: account) + end + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze belongs_to :family belongs_to :account, optional: true + belongs_to :account_statement, optional: true before_validation :set_default_number_format before_validation :ensure_utf8_encoding diff --git a/app/models/pdf_import.rb b/app/models/pdf_import.rb index f610e6b0f..b80725793 100644 --- a/app/models/pdf_import.rb +++ b/app/models/pdf_import.rb @@ -2,6 +2,46 @@ class PdfImport < Import has_one_attached :pdf_file, dependent: :purge_later validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true + validate :account_statement_belongs_to_family + validate :account_statement_is_pdf + + class << self + def create_from_upload!(family:, file:, user:) + prepared_upload = AccountStatement.prepare_upload!(file) + statement = AccountStatement.create_from_prepared_upload!( + family: family, + account: nil, + prepared_upload: prepared_upload + ) + + create_from_statement!(statement: statement) + rescue AccountStatement::DuplicateUploadError => e + raise unless e.statement.viewable_by?(user) + + create_from_statement!(statement: e.statement) + end + + def create_from_statement!(statement:) + reusable_import = statement.latest_reusable_pdf_import + return reusable_import if reusable_pdf_import_current?(reusable_import, statement) + + create!( + family: statement.family, + account: statement.account, + account_statement: statement, + date_format: statement.family.date_format, + status: :pending + ) + end + + private + + def reusable_pdf_import_current?(pdf_import, statement) + pdf_import.present? && + pdf_import.account_id == statement.account_id && + pdf_import.date_format == statement.family.date_format + end + end def import! raise "Account required for PDF import" unless account.present? @@ -31,8 +71,19 @@ def import! end end + def assign_account!(account) + transaction do + update!(account: account) + statement = account_statement + if statement.present? && account.present? + statement.lock! + statement.link_to_account!(account) if statement.account_id != account.id + end + end + end + def pdf_uploaded? - pdf_file.attached? + source_pdf_attached? end def ai_processed? @@ -40,7 +91,25 @@ def ai_processed? end def process_with_ai_later - ProcessPdfJob.perform_later(self) + should_enqueue = with_lock do + if pending? && !ai_processed? && rows_count.zero? + update!(status: :importing) + true + else + false + end + end + + return false unless should_enqueue + + begin + ProcessPdfJob.perform_later(self) + true + rescue StandardError => e + Rails.logger.error("Failed to enqueue PDF processing for import #{id}: #{e.class.name} - #{e.message}") + reload.with_lock { update!(status: :pending) } + false + end end def process_with_ai @@ -172,9 +241,29 @@ def requires_csv_workflow? end def pdf_file_content - return nil unless pdf_file.attached? + return @pdf_file_content if defined?(@pdf_file_content) - pdf_file.download + @pdf_file_content = if statement_backed? + account_statement.original_file.download + elsif pdf_file.attached? + pdf_file.download + end + end + + def pdf_filename + if statement_backed? + account_statement.filename + elsif pdf_file.attached? + pdf_file.filename.to_s + end + end + + def statement_backed? + account_statement&.original_file&.attached? + end + + def legacy_pdf_file? + pdf_file.attached? end def required_column_keys @@ -199,4 +288,22 @@ def format_date_for_import(date_str) rescue ArgumentError date_str.to_s end + + def source_pdf_attached? + statement_backed? || legacy_pdf_file? + end + + def account_statement_belongs_to_family + return if account_statement.blank? + return if account_statement.family_id == family_id + + errors.add(:account_statement, :invalid) + end + + def account_statement_is_pdf + return if account_statement.blank? + return if account_statement.pdf? + + errors.add(:account_statement, :invalid) + end end diff --git a/app/views/account_statements/show.html.erb b/app/views/account_statements/show.html.erb index e320fc79a..69ae21674 100644 --- a/app/views/account_statements/show.html.erb +++ b/app/views/account_statements/show.html.erb @@ -138,6 +138,32 @@ <% end %> +
+

<%= t("account_statements.extract.title") %>

+ + <% if @statement.pdf? %> +
+ <% if @latest_pdf_import.present? %> + <%= link_to import_path(@latest_pdf_import), class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-primary-hover" do %> + <%= icon("file-search", size: "sm") %> + <%= t("account_statements.extract.latest_import") %> + <% end %> + <% end %> + + <% if @can_manage_statement %> + <%= button_to extract_account_statement_path(@statement), + method: :post, + class: "inline-flex items-center gap-2 text-sm font-medium text-primary" do %> + <%= icon("scan-text", size: "sm") %> + <%= t("account_statements.extract.start") %> + <% end %> + <% end %> +
+ <% else %> +

<%= t("account_statements.extract.unavailable") %>

+ <% end %> +
+

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

diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index cb577369e..99d5a29fe 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -6,7 +6,9 @@ <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %> <% end %> <% menu.with_item(variant: "link", text: "Sharing", href: account_sharing_path(account), icon: "users", data: { turbo_frame: :modal }) %> - <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %> + <% if permission.in?([ :owner, :full_control ]) && AccountStatement.statement_manager?(Current.user) %> + <% menu.with_item(variant: "link", text: t(".statements"), href: account_path(account, tab: "statements"), icon: "archive") %> + <% end %> <% if permission.in?([ :owner, :full_control ]) %> <% if account.supports_trades? %> diff --git a/app/views/imports/_pdf_import.html.erb b/app/views/imports/_pdf_import.html.erb index 068d55da9..097087a7b 100644 --- a/app/views/imports/_pdf_import.html.erb +++ b/app/views/imports/_pdf_import.html.erb @@ -14,16 +14,25 @@
+ <% if import.account_statement.present? %> +
+

<%= t("imports.pdf_import.source_statement") %>

+

+ <%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %> +

+
+ <% end %> +

<%= t("imports.pdf_import.document_type_label") %>

-

+

<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>

<%= t("imports.pdf_import.transactions_extracted", default: "Transactions Extracted") %>

-

+

<%= t("imports.pdf_import.transactions_extracted_count", count: import.rows_count, default: "%{count} transactions") %>

@@ -106,16 +115,25 @@
+ <% if import.account_statement.present? %> +
+

<%= t("imports.pdf_import.source_statement") %>

+

+ <%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %> +

+
+ <% end %> +

<%= t("imports.pdf_import.document_type_label") %>

-

+

<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>

<%= t("imports.pdf_import.summary_label") %>

-

+

<%= import.ai_summary %>

diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb index 19bd4a241..a48dd4019 100644 --- a/app/views/settings/_settings_nav.html.erb +++ b/app/views/settings/_settings_nav.html.erb @@ -61,7 +61,7 @@ nav_sections = [ esc <% end %>
-