feat(merchants): add raw data import (csv) for merchants (#1992)

* feat(merchants): add csv import endpoint for merchants

* docs: update endpoint docs

* fix(merchant): recommended ai fixes
This commit is contained in:
Blaž Dular
2026-06-06 16:33:32 +02:00
committed by GitHub
parent d88d6e9e58
commit 94422955f8
18 changed files with 512 additions and 37 deletions

View File

@@ -2,29 +2,14 @@
module Api
module V1
# API v1 endpoint for merchants
# Provides read-only access to family and provider merchants
#
# @example List all merchants
# GET /api/v1/merchants
#
# @example Get a specific merchant
# GET /api/v1/merchants/:id
#
class MerchantsController < BaseController
before_action -> { authorize_scope!(:read) }
before_action -> { authorize_scope!(:read) }, only: [ :index, :show ]
before_action -> { authorize_scope!(:write) }, only: [ :create ]
# List all merchants available to the family
#
# Returns both family-owned merchants and provider merchants
# that are assigned to the family's transactions.
#
# @return [Array<Hash>] JSON array of merchant objects
def index
family = current_resource_owner.family
user = current_resource_owner
# Single query with OR conditions - more efficient than Ruby deduplication
family_merchant_ids = family.merchants.select(:id)
accessible_account_ids = family.accounts.accessible_by(user).select(:id)
provider_merchant_ids = Transaction.joins(:entry)
@@ -44,13 +29,6 @@ module Api
render json: { error: "Failed to fetch merchants" }, status: :internal_server_error
end
# Get a specific merchant by ID
#
# Returns a merchant if it belongs to the family or is assigned
# to any of the family's transactions.
#
# @param id [String] The merchant ID
# @return [Hash] JSON merchant object or error
def show
family = current_resource_owner.family
user = current_resource_owner
@@ -71,12 +49,83 @@ module Api
render json: { error: "Failed to fetch merchant" }, status: :internal_server_error
end
def create
family = current_resource_owner.family
unless params[:file].present?
return render json: { error: "missing_file", message: "Please provide a CSV file." },
status: :unprocessable_entity
end
file = params[:file]
if file.size > Import::MAX_CSV_SIZE
return render json: {
error: "file_too_large",
message: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
}, status: :unprocessable_entity
end
unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type)
return render json: {
error: "invalid_file_type",
message: "Invalid file type. Please upload a CSV file."
}, status: :unprocessable_entity
end
csv = Import.parse_csv_str(file.read)
name_header = normalized_header(csv.headers, "name")
unless name_header
return render json: {
error: "missing_column",
message: "CSV must include a 'name' column."
}, status: :unprocessable_entity
end
color_header = normalized_header(csv.headers, "color")
website_url_header = normalized_header(csv.headers, "website_url", "website url", "website")
imported = []
skipped = []
csv.each do |row|
name = row[name_header].to_s.strip
next if name.blank?
merchant = family.merchants.find_or_initialize_by(name: name)
if merchant.persisted?
skipped << { name: name, reason: "already_exists" }
next
end
merchant.color = row[color_header].to_s.strip.presence if color_header
merchant.website_url = row[website_url_header].to_s.strip.presence if website_url_header
if merchant.save
imported << merchant
else
skipped << { name: name, errors: merchant.errors.full_messages }
end
end
render json: {
imported: imported.count,
skipped: skipped.count,
merchants: imported.map { |m| merchant_json(m) }
}, status: :created
rescue CSV::MalformedCSVError => e
render json: { error: "invalid_csv", message: "CSV could not be parsed: #{e.message}" },
status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error("API Merchants Import Error: #{e.message}")
render json: { error: "internal_server_error", message: "An unexpected error occurred" },
status: :internal_server_error
end
private
# Serialize a merchant to JSON format
#
# @param merchant [Merchant] The merchant to serialize
# @return [Hash] JSON-serializable hash
def merchant_json(merchant)
{
id: merchant.id,
@@ -86,6 +135,19 @@ module Api
updated_at: merchant.updated_at
}
end
def normalized_header(headers, *candidates)
normalized_map = headers.to_h { |h| [ normalize(h), h ] }
candidates.each do |candidate|
header = normalized_map[normalize(candidate)]
return header if header.present?
end
nil
end
def normalize(str)
str.to_s.strip.downcase.gsub(/\*/, "").gsub(/[\s_-]+/, "_")
end
end
end
end