Skip to content

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

Open
xBlaz3kx wants to merge 3 commits into
we-promise:mainfrom
xBlaz3kx:feat/import-merchants
Open

feat(merchants): add raw data import (csv) for merchants#1992
xBlaz3kx wants to merge 3 commits into
we-promise:mainfrom
xBlaz3kx:feat/import-merchants

Conversation

@xBlaz3kx
Copy link
Copy Markdown
Contributor

@xBlaz3kx xBlaz3kx commented May 25, 2026

Summary

  • Adds a POST /api/v1/merchants endpoint that bulk-creates FamilyMerchant records from an uploaded CSV file (requires write scope). Existing merchants with the same name are skipped without error.
  • Adds a MerchantImport type to the web UI multi-step import flow (Settings → Imports → New Import → Raw Data → Import merchants), following the same pattern as CategoryImport.
  • Adds an "Import merchants" shortcut button to the Merchants page header.

Summary by CodeRabbit

  • New Features

    • CSV-based merchant import via web UI and API with upload, mapping, and summary (imported vs skipped)
    • Merchants page now shows an "Import merchants" action and a merchant import option in the new-import flow
  • Bug Fixes / Validation

    • Stronger CSV validation (required name column, allowed file types/sizes, duplicate/ malformed column detection) and per-row error reporting
  • Documentation

    • API docs updated with POST /api/v1/merchants import spec, examples, and error codes
  • Tests

    • Request/controller tests added for CSV import success and failure cases

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

📝 Walkthrough

Walkthrough

Adds CSV-based merchant bulk-import: MerchantImport model, DB columns, API POST /api/v1/merchants (write scope) with header normalization/validation, web UI integration, tests, and API documentation.

Changes

Merchant CSV Import Feature

Layer / File(s) Summary
MerchantImport model and CSV schema
app/models/merchant_import.rb, app/models/import.rb, db/migrate/20260525202505_add_merchant_columns_to_import_rows.rb, db/schema.rb
Adds MerchantImport implementing CSV header normalization, required-header validation, row generation, and family-scoped merchant upsert; adds merchant_color and merchant_website columns to import_rows.
API endpoint with authorization
app/controllers/api/v1/merchants_controller.rb, config/routes.rb
Adds POST /api/v1/merchants create action requiring write scope; validates uploaded CSV (presence, size, MIME), normalizes headers, imports merchants while skipping existing ones, handles CSV parse errors, and scopes index/show to read.
API tests and documentation
spec/requests/api/v1/merchants_spec.rb, spec/swagger_helper.rb, docs/api/merchants.md
Request specs for the POST import (success and failure cases), OpenAPI MerchantImportResult schema, and expanded API docs describing CSV format, header matching, responses, and web UI import pointer.
Controller unit tests
test/controllers/api/v1/merchants_controller_test.rb
Adds tests for authentication and scope enforcement, successful CSV import and duplicate skipping, specific 422 validation cases, and new helpers for API keys and CSV uploads.
Web UI integration
app/controllers/imports_controller.rb, app/helpers/imports_helper.rb, app/views/family_merchants/index.html.erb, app/views/import/configurations/_merchant_import.html.erb, app/views/imports/new.html.erb, config/locales/views/imports/en.yml, config/locales/views/merchants/en.yml
Adds merchant import option in new-import UI, merchant import configuration partial, link on family merchants index, allowed import type, ImportsController redirect behavior for mapping steps, and locale strings for merchant columns, labels, and errors.

Sequence Diagram

sequenceDiagram
  participant Client
  participant API
  participant MerchantsController
  participant MerchantImport
  participant Family
  participant Database
  participant Response

  Client->>API: POST /api/v1/merchants (CSV file + X-Api-Key)
  API->>MerchantsController: route to create
  MerchantsController->>MerchantsController: authorize_scope!(:write)
  MerchantsController->>MerchantsController: validate file (presence, size, MIME)
  MerchantsController->>MerchantImport: new + generate_rows_from_csv(file)
  MerchantImport->>MerchantImport: validate_required_headers! / normalize headers
  MerchantsController->>MerchantImport: import!
  MerchantImport->>Family: use current_family
  loop for each row
    MerchantImport->>Database: find_or_initialize_by(name) scoped to family
    MerchantImport->>Database: save if new_record? (set color, website_url)
  end
  MerchantImport-->>MerchantsController: imported/skipped counts + merchants
  MerchantsController->>Response: render JSON 201 created
  Response-->>Client: MerchantImportResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • we-promise/sure#418: View changes overlap with family merchants index where the import link was added.
  • we-promise/sure#1208: Related changes to imports new UI layout that affect where the MerchantImport option appears.
  • we-promise/sure#1830: Related to import-type registration (Import::TYPES / ActualImport) used alongside this merchant import.

Suggested reviewers

  • jjmata
  • sokie

Poem

🐰 I hopped a CSV across the wire,
Rows of merchants, colors afire,
Headers tamed and duplicates skipped,
Families grew as records slipped.
Little import, big delight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The pull request title directly and accurately describes the main change: adding raw data (CSV) import functionality for merchants across both API and web UI.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@xBlaz3kx xBlaz3kx force-pushed the feat/import-merchants branch from 3edc98c to ff5e83f Compare May 25, 2026 19:42
@xBlaz3kx xBlaz3kx marked this pull request as ready for review May 25, 2026 19:42
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 25, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/controllers/api/v1/merchants_controller.rb`:
- Around line 121-124: The rescue block in MerchantsController currently returns
a generic 500 payload; update the render in the rescue StandardError => e
handler so the JSON uses the API v1 standard message format: message: "Error:
#{e.message}" (keep the existing Rails.logger.error call and status:
:internal_server_error), i.e., locate the rescue StandardError => e block in
app/controllers/api/v1/merchants_controller.rb (references: rescue StandardError
=> e, Rails.logger.error, render json) and change the message value to "Error:
#{e.message}" for consistency with API v1.

In `@app/models/merchant_import.rb`:
- Around line 54-63: The CSV import loop creates row records (csv_rows.each /
rows.create!) but never updates the MerchantImport.rows_count attribute, which
leaves configured?/publishable? incorrect; after inserting the rows (or after
the loop), set self.rows_count to the new count (e.g. rows.reload.count or
csv_rows.size) and persist it (use update_column(:rows_count, ...) or
update!(rows_count: ...) depending on whether you need callbacks/validations) so
configured? and publishable? reflect the actual number of rows.
- Around line 84-86: normalized_headers currently uses csv_headers.to_h which
silently overwrites duplicate normalized keys (e.g.,
normalize_header("website-url") == normalize_header("website url")); change
normalized_headers to iterate csv_headers and build the hash manually using
normalize_header(header) as the key, and if a normalized key is already present
collect the duplicates and raise the same validation error used by the base
Import (or add to import.errors) so duplicates are reported instead of silently
overwritten; update references to normalized_headers, csv_headers, and
normalize_header when implementing this check.

In `@app/views/family_merchants/index.html.erb`:
- Around line 9-14: The DS::Link invocation for the import button opens a full
page and breaks the modal turbo-frame flow; update the DS::Link.new call that
renders the import button (the existing call with text: t(".import") and href:
new_import_path(type: "MerchantImport")) to target the modal turbo frame by
adding the turbo frame attribute (e.g., data: { turbo_frame: "modal" } or the
component's supported frame/target option) so the new_import_path loads inside
the modal frame rather than replacing the whole page.

In `@app/views/imports/new.html.erb`:
- Around line 228-236: The render call for the import option passes raw Tailwind
palette classes (icon_bg_class: "bg-orange-500/5", icon_text_class:
"text-orange-500") which violates the design-system token rule; update the
partial invocation (render "imports/import_option" for MerchantImport) to use
the project's design tokens from sure-design-system.css (e.g., replace
icon_bg_class with the container/background token such as "bg-container" or the
equivalent token your system uses, and replace icon_text_class with a semantic
text token like "text-primary" or the agreed token) so the partial receives
semantic design tokens instead of raw palette classes.

In `@docs/api/merchants.md`:
- Around line 173-177: Update the API docs table row for HTTP status 403 so the
`error` value matches the actual API response: replace `forbidden` with
`insufficient_scope` in the 403 row (the table entry showing "403 | `forbidden`
| API key lacks the `write` scope.") so the documented error string aligns with
the API's `error: "insufficient_scope"`.

In `@spec/requests/api/v1/merchants_spec.rb`:
- Around line 75-85: Add a new rswag response block for response '403'
'forbidden' inside the POST /api/v1/merchants spec: create a response '403'
entry that uses the same ErrorResponse schema and simulates an authenticated
client that lacks the required write scope by setting the request auth
header/fixture used elsewhere (e.g., set the API key or token variable used for
auth such as let(:'X-Api-Key') or the token helper) to an API key/token with
read-only scope; ensure the block calls run_test! so the 403 scope-denied case
is documented and executed alongside the existing 401 and 422 cases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 188156c6-19ce-4477-bd3b-371559b7f1b3

📥 Commits

Reviewing files that changed from the base of the PR and between d8a12ad and ff5e83f.

📒 Files selected for processing (18)
  • app/controllers/api/v1/merchants_controller.rb
  • app/controllers/imports_controller.rb
  • app/helpers/imports_helper.rb
  • app/models/import.rb
  • app/models/merchant_import.rb
  • app/views/family_merchants/index.html.erb
  • app/views/import/configurations/_merchant_import.html.erb
  • app/views/imports/new.html.erb
  • config/locales/models/merchant_import/en.yml
  • config/locales/views/imports/en.yml
  • config/locales/views/merchants/en.yml
  • config/routes.rb
  • db/migrate/20260525202505_add_merchant_columns_to_import_rows.rb
  • db/schema.rb
  • docs/api/merchants.md
  • spec/requests/api/v1/merchants_spec.rb
  • spec/swagger_helper.rb
  • test/controllers/api/v1/merchants_controller_test.rb

Comment thread app/controllers/api/v1/merchants_controller.rb
Comment thread app/models/merchant_import.rb
Comment thread app/models/merchant_import.rb
Comment thread app/views/family_merchants/index.html.erb
Comment thread app/views/imports/new.html.erb
Comment thread docs/api/merchants.md
Comment thread spec/requests/api/v1/merchants_spec.rb
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/controllers/api/v1/merchants_controller.rb (1)

121-124: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the API v1 standard 500 error message format.

At Line 123, returning a generic message diverges from the API v1 convention and prior controller behavior.

Proposed fix
-        render json: { error: "internal_server_error", message: "An unexpected error occurred" },
+        render json: { error: "internal_server_error", message: "Error: #{e.message}" },
                status: :internal_server_error

Based on learnings: In app/controllers/api/v1/**/*.rb, 500 responses should use message: "Error: #{e.message}" consistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/controllers/api/v1/merchants_controller.rb` around lines 121 - 124, The
rescue block in the API v1 Merchants controller is returning a generic 500
message; update the rescue StandardError => e handler (the block containing
Rails.logger.error(... ) and the render json call) to follow API v1 convention
by rendering json with message: "Error: #{e.message}" (keep status:
:internal_server_error and existing Rails.logger.error call intact) so the
response matches other controllers in app/controllers/api/v1/*.rb.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/merchant_import.rb`:
- Around line 89-100: The duplicate-detection loop in csv_headers should skip
blank normalized headers so trailing empty columns don't produce a duplicate
empty key; inside the csv_headers.each loop (where normalize_header(header) is
assigned to key and result/duplicates are updated), add a guard to next if
key.blank? (or key == ""), so you only populate result and mark duplicates for
non-blank keys before adding the :duplicate_columns error and raising
ActiveRecord::RecordInvalid.

---

Duplicate comments:
In `@app/controllers/api/v1/merchants_controller.rb`:
- Around line 121-124: The rescue block in the API v1 Merchants controller is
returning a generic 500 message; update the rescue StandardError => e handler
(the block containing Rails.logger.error(... ) and the render json call) to
follow API v1 convention by rendering json with message: "Error: #{e.message}"
(keep status: :internal_server_error and existing Rails.logger.error call
intact) so the response matches other controllers in
app/controllers/api/v1/*.rb.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bc9e7909-64e1-47ea-b9ff-25ae43d3ef61

📥 Commits

Reviewing files that changed from the base of the PR and between ff5e83f and 6464f5a.

📒 Files selected for processing (3)
  • app/controllers/api/v1/merchants_controller.rb
  • app/models/merchant_import.rb
  • config/locales/models/merchant_import/en.yml

Comment on lines +89 to +100
csv_headers.each do |header|
key = normalize_header(header)
if result.key?(key)
duplicates << header
else
result[key] = header
end
end

if duplicates.any?
errors.add(:base, :duplicate_columns, columns: duplicates.join(", "))
raise ActiveRecord::RecordInvalid.new(self)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Ignore blank normalized headers in duplicate detection.

At Line 90-Line 92, blank headers normalize to an empty key and can trigger duplicate_columns on CSVs with trailing empty columns, causing false import failures.

Proposed fix
         csv_headers.each do |header|
           key = normalize_header(header)
+          next if key.blank?
+
           if result.key?(key)
             duplicates << header
           else
             result[key] = header
           end
         end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
csv_headers.each do |header|
key = normalize_header(header)
if result.key?(key)
duplicates << header
else
result[key] = header
end
end
if duplicates.any?
errors.add(:base, :duplicate_columns, columns: duplicates.join(", "))
raise ActiveRecord::RecordInvalid.new(self)
csv_headers.each do |header|
key = normalize_header(header)
next if key.blank?
if result.key?(key)
duplicates << header
else
result[key] = header
end
end
if duplicates.any?
errors.add(:base, :duplicate_columns, columns: duplicates.join(", "))
raise ActiveRecord::RecordInvalid.new(self)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/merchant_import.rb` around lines 89 - 100, The duplicate-detection
loop in csv_headers should skip blank normalized headers so trailing empty
columns don't produce a duplicate empty key; inside the csv_headers.each loop
(where normalize_header(header) is assigned to key and result/duplicates are
updated), add a guard to next if key.blank? (or key == ""), so you only populate
result and mark duplicates for non-blank keys before adding the
:duplicate_columns error and raising ActiveRecord::RecordInvalid.

@xBlaz3kx xBlaz3kx changed the title feat(merchants): add csv import endpoint for merchants feat(merchants): add raw data import (csv) for merchants May 25, 2026
Copy link
Copy Markdown
Collaborator

jjmata commented May 26, 2026

Good feature addition that follows the CategoryImport conventions well. A few things worth flagging:

import! transaction + save!: The entire import is wrapped in a single transaction do with save!. A single validation failure on any row will roll back all previously imported merchants in that batch. This matches CategoryImport's approach, so it's consistent, but it means a CSV with 99 valid rows and 1 invalid one imports nothing. Consider whether a per-row error collection (like the API path's skipped array) would be more user-friendly in the web flow.

FamilyMerchant::COLORS.sample on import: A random color is assigned to merchants without a color column value. Since existing merchants are skipped, the only affected merchants are new ones — and re-running the import would skip them. So in practice colors are stable. Not a concern unless someone deletes and re-imports.

normalize_header duplication: The same normalization logic (strip asterisks, normalize whitespace/underscores) appears in both MerchantImport#normalize_header and Api::V1::MerchantsController#normalize. Not a blocker for this PR, but if a third import type needs the same logic a shared module would be cleaner.

imports_controller.rb fix: The redirect to import_clean_path when mapping_steps.empty? is a correct bug fix for import types with no column mapping step. Worth confirming this doesn't affect other import types that do have mapping steps — since the condition is @import.mapping_steps.empty?, existing types with steps are unaffected.

API test coverage: Auth, scope, import, skip, missing file, missing column, and invalid file type are all tested. The only gap is the file-too-large case (Import::MAX_CSV_SIZE), but that's a minor omission.

rswag spec: Uses run_test! only — correct, no behavioral assertions. The MerchantImportResult schema is registered in swagger_helper.rb as expected.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. not-gittensor pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants