Skip to content

Feat/invoice categories tags#48

Merged
emilwojtaszek merged 14 commits into
mainfrom
feat/invoice-categories-tags
Feb 23, 2026
Merged

Feat/invoice categories tags#48
emilwojtaszek merged 14 commits into
mainfrom
feat/invoice-categories-tags

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 23, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Invoice categories and tags: full CRUD, assign/clear categories, add/remove/set/list tags on invoices; invoice show/list include category & tags and support filtering by category_id and tag_ids; new API endpoints.
  • Documentation

    • OpenAPI schemas and request/response docs for categories, tags, and invoice tag/category operations.
  • Tests

    • Extensive test coverage for category/tag workflows, associations, APIs, and filtering.
  • Chores

    • Database migrations for categories, tags, and join table; updated test factories.

emilwojtaszek and others added 8 commits February 23, 2026 07:39
Create categories table with company scoping and unique name constraint,
tags table with company scoping, and invoice_tags join table. Add
category_id foreign key to invoices table with nilify_all on delete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add three new Ecto schemas for invoice categorization:
- Category with group:target name format validation
- Tag with virtual usage_count field
- InvoiceTag join schema

Update Invoice schema with category/tag associations and
category_changeset/2 for dedicated category assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement full CRUD for categories and tags with company scoping,
invoice-category assignment, invoice-tag associations (add/remove/set/list),
and filter extensions for category_id and tag_ids in list_invoices.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Create 12 new OpenAPI schema files for category/tag CRUD request/response
types. Update Invoice schema with category_id, category, and tags properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New controllers:
- CategoryController: full CRUD at /api/categories
- TagController: full CRUD at /api/tags

Extended InvoiceController:
- PUT /api/invoices/:id/category (set/clear category)
- POST /api/invoices/:id/tags (add tags)
- PUT /api/invoices/:id/tags (replace tags)
- DELETE /api/invoices/:id/tags/:tag_id (remove tag)
- category_id and tag_ids[] query filters on index
- Show endpoint preloads category and tags

All endpoints include OpenAPI specs, company scoping, and cross-company
validation. 76 controller tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Apply mix format and replace single-clause with/else with case
in add_tags action per credo --strict.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Extract category_json/1, tag_json/1, atomize_keys/2 into shared
  KsefHubWeb.JsonHelpers module, removing duplicates across 3 controllers
- Move Repo.preload from controller into context (get_invoice_with_details!)
- Replace wasteful validate_tags_company (loaded all tags with GROUP BY)
  with efficient tags_belong_to_company? (single COUNT query)
- Fix count_invoices + tag_ids distinct bug by wrapping in subquery()
- Replace raw maps in set_invoice_tags with proper InvoiceTag changesets
- DRY up get_category/get_category! and get_tag/get_tag! with base queries
- Add missing OpenAPI parameters for category_id and tag_ids[] filters
- Remove redundant Map.take calls (atomize_keys already filters)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Category and Tag domain models, invoice associations and join table, context APIs for category/tag CRUD and invoice-tag management, controller endpoints and routes, JSON helpers and OpenAPI schemas, DB migrations, and comprehensive tests for filtering and association behavior.

Changes

Cohort / File(s) Summary
Domain schemas & Invoice
lib/ksef_hub/invoices/category.ex, lib/ksef_hub/invoices/tag.ex, lib/ksef_hub/invoices/invoice_tag.ex, lib/ksef_hub/invoices/invoice.ex
New Ecto schemas Category, Tag, InvoiceTag; Invoice gains category_id association, invoice_tags/tags relations and category_changeset; validations, FK and unique constraints added.
Context APIs
lib/ksef_hub/invoices.ex
Added category and tag CRUD, get_invoice_with_details!/3, set_invoice_category, add/remove/list/set_invoice_tags, tags_belong_to_company?/2, private category_query/tag_query, extended apply_filters for category_id/tag_ids, and count_invoices subquery handling.
API controllers & Router
lib/ksef_hub_web/controllers/api/category_controller.ex, lib/ksef_hub_web/controllers/api/tag_controller.ex, lib/ksef_hub_web/controllers/api/invoice_controller.ex, lib/ksef_hub_web/router.ex
New Category and Tag API controllers (CRUD); InvoiceController gains set_category, add_tags, set_tags, remove_tag; routes/resources added under /api.
JSON helpers & serialization
lib/ksef_hub_web/json_helpers.ex, lib/ksef_hub_web/controllers/api/invoice_controller.ex
Added category_json/1, tag_json/1, and atomize_keys/2 whitelist; invoice JSON extended with category_id, optional embedded category and tags; controller uses whitelist atomization and validation helpers.
OpenAPI schemas
lib/ksef_hub_web/schemas/*
Added OpenApiSpex schemas for Category/Tag, request/response wrappers, Create/Update request schemas, InvoiceTagsRequest, and extended invoice schema with category_id, category, and tags.
Migrations
priv/repo/migrations/20260223063931_create_categories.exs, priv/repo/migrations/20260223063932_create_tags.exs
Create categories, tags, and invoice_tags tables; add invoices.category_id nullable FK; indexes and unique constraints added; on_delete behaviors set.
Tests & Factories
test/ksef_hub/invoices/{categories,tags}_test.exs, test/ksef_hub_web/controllers/api/*_controller_test.exs, test/support/factory.ex
Extensive unit and controller tests for category/tag CRUD, invoice association management, filtering by category_id/tag_ids; new factories for category, tag, invoice_tag.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API as rgba(70,130,180,0.5) "API Controller"
  participant Context as rgba(34,139,34,0.5) "Invoices Context"
  participant DB as rgba(128,0,128,0.5) "Repo/DB"

  Client->>API: PUT /api/invoices/:id/category {category_id}
  API->>Context: set_invoice_category(invoice_id, category_id, company_id)
  Context->>DB: fetch invoice scoped by company
  DB-->>Context: invoice
  Context->>DB: update invoice (category_changeset)
  DB-->>Context: {:ok, updated_invoice}
  Context->>DB: preload category and tags
  DB-->>Context: invoice with associations
  Context-->>API: {:ok, invoice}
  API-->>Client: 200 {data: invoice_json}
Loading
sequenceDiagram
  participant Client
  participant API as rgba(70,130,180,0.5) "API Controller"
  participant Context as rgba(34,139,34,0.5) "Invoices Context"
  participant DB as rgba(128,0,128,0.5) "Repo/DB"

  Client->>API: POST /api/invoices/:id/tags {tag_ids}
  API->>Context: set_invoice_tags(invoice_id, tag_ids, company_id)
  Context->>DB: verify tags belong to company (tag_query)
  DB-->>Context: tag records
  Context->>DB: delete existing invoice_tags for invoice (if replacing)
  DB-->>Context: {:ok, _}
  Context->>DB: insert invoice_tag rows
  DB-->>Context: {:ok, inserted_rows}
  Context->>DB: preload tags for invoice
  DB-->>Context: invoice with tags
  Context-->>API: {:ok, tags}
  API-->>Client: 200 {data: [tag_json,...]}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Feat/api token scoping #33 — Overlaps changes to lib/ksef_hub_web/controllers/api/invoice_controller.ex (index/show behavior and new tag/category handling).
  • Feat/invoice pagination #37 — Related edits to lib/ksef_hub/invoices.ex (listing, counting, and filter logic) that intersect with tag/category filtering and count subquery changes.
  • Feat/reviewer expense only #43 — Changes to KsefHub.Invoices (get/list semantics and filtering) that closely align with the new invoice detail and filtering behavior here.

Poem

🐰 I hopped through schemas, tags in tow,
I sorted categories row by row,
I nudged invoices to match each name,
Filters now play a clever game,
A rabbit cheers — code baked, not slow.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/invoice categories tags' is directly related to the main changeset, which implements comprehensive invoice categories and tags handling across the codebase.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/invoice-categories-tags

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (3)
lib/ksef_hub/invoices/tag.ex (1)

12-19: Use the InvoiceTag join schema module reference for consistency.

The Invoice schema already uses join_through: KsefHub.Invoices.InvoiceTag on its side of the relationship. Update the Tag schema to match, which enables Ecto to leverage the InvoiceTag validations and constraints when managing associations from either side.

♻️ Suggested change
-    many_to_many :invoices, KsefHub.Invoices.Invoice, join_through: "invoice_tags"
+    many_to_many :invoices, KsefHub.Invoices.Invoice,
+      join_through: KsefHub.Invoices.InvoiceTag
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices/tag.ex` around lines 12 - 19, The Tag schema's
many_to_many association uses a string join table; change the many_to_many
:invoices association in KsefHub.Invoices.Tag (schema "tags") to use the
join_through module KsefHub.Invoices.InvoiceTag instead of "invoice_tags" so it
matches the Invoice schema and allows Ecto to apply InvoiceTag
validations/constraints when managing associations from either side.
test/ksef_hub_web/controllers/api/tag_controller_test.exs (1)

91-113: Consider adding a test for invalid update validation.

The update describe block tests successful updates and cross-company 404s, but doesn't test validation errors (e.g., empty name). This would align with the create and CategoryController patterns.

💡 Suggested test addition
test "returns 422 for invalid update", %{conn: conn} do
  %{company: company, token: token} = create_owner_with_token()
  tag = insert(:tag, company: company)

  body = Jason.encode!(%{name: ""})
  conn = conn |> api_conn(token) |> put("/api/tags/#{tag.id}", body)

  assert conn.status == 422
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/ksef_hub_web/controllers/api/tag_controller_test.exs` around lines 91 -
113, Add a test in the "update" describe block that asserts validation errors
return 422: using create_owner_with_token() to get company and token,
insert(:tag, company: company) to create the tag, call api_conn(token) |>
put("/api/tags/#{tag.id}", body) with a JSON body where name is "" and assert
conn.status == 422; place it alongside the existing "updates a tag" and
cross-company 404 tests so update validations are covered like in the create and
CategoryController tests.
lib/ksef_hub_web/schemas/invoice.ex (1)

86-108: Consider adding new fields to the example.

The example payload doesn't include category_id, category, or tags. Adding these would improve API documentation clarity.

📝 Suggested example additions
       source: "ksef",
+      category_id: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+      category: %{
+        id: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
+        name: "ops:invoices"
+      },
+      tags: [
+        %{id: "c3d4e5f6-a7b8-9012-cdef-123456789012", name: "urgent"}
+      ],
       duplicate_of_id: nil,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/schemas/invoice.ex` around lines 86 - 108, Update the
example map in the Invoice schema's `example` (in module Invoice) to include the
missing fields `category_id`, `category`, and `tags`; add `category_id` as a
UUID string (e.g. "d4c3b2a1-..."), `category` as a short human-readable string
(e.g. "office supplies"), and `tags` as a list of strings (e.g.
["urgent","quarterly"]) so the documented example reflects those schema
attributes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 699-703: maybe_put_list/3 currently only matches lists and nil,
causing a FunctionClauseError when query params like tag_ids arrive as a single
string; add a clause to handle binaries (e.g., when tag_ids is a string) by
converting the string to a single-element list (Map.put(map, key, [value])), and
add a fallback clause defp maybe_put_list(map, _key, _invalid), do: map to
safely ignore other invalid types; update function maybe_put_list/3 to include
these clauses so tag_ids and similar params are handled without raising.
- Around line 558-603: The handlers add_tags and set_tags currently read
params["tag_ids"] directly and pass it to Invoices.tags_belong_to_company?/2
which can crash on non-list input; validate/normalize tag_ids before calling
downstream functions: ensure tag_ids is nil or a list (e.g., coerce nil to []
and reject non-list types), verify elements are the expected type/format
(strings/UUIDs) or at least ensure Enum-safe usage, and if invalid return conn
|> put_status(:unprocessable_entity) |> json(%{error: "Invalid tag_ids
payload"}) instead of proceeding; update both add_tags and set_tags to perform
this check prior to calling Invoices.tags_belong_to_company?/2 (and before
Enum.each or Invoices.set_invoice_tags/1) so malformed input yields 422 rather
than a 500.

In `@lib/ksef_hub/invoices.ex`:
- Around line 493-508: The set_invoice_tags/2 function currently uses
Repo.insert!/1 inside the Repo.transaction which will raise on unique_constraint
violations; replace the Enum.each + Repo.insert!/1 with a safe insert flow using
Repo.insert/1 and rollback on error: iterate tag_ids (e.g., with
Enum.reduce_while), call InvoiceTag.changeset/1 and Repo.insert/1 for each, on
{:ok, _} continue and on {:error, changeset} call Repo.rollback(changeset) so
the transaction returns {:error, changeset} instead of crashing; ensure the
final successful branch returns the list from list_invoice_tags(invoice_id)
wrapped as {:ok, tags} to match the `@spec`.

In `@lib/ksef_hub/invoices/invoice_tag.ex`:
- Around line 21-27: Remove invoice_id and tag_id from being mass-assignable in
the changeset: in the changeset/2 function of invoice_tag.ex remove
[:invoice_id, :tag_id] from the cast and from validate_required (do not rely on
attrs to supply them). Keep the constraints (unique_constraint,
foreign_key_constraint) but ensure callers (e.g., add_invoice_tag/2 and the bulk
tag operations in invoices.ex) set those values using put_change when building
the changeset, following the same pattern used for server-set fields in
credentials.ex.

---

Nitpick comments:
In `@lib/ksef_hub_web/schemas/invoice.ex`:
- Around line 86-108: Update the example map in the Invoice schema's `example`
(in module Invoice) to include the missing fields `category_id`, `category`, and
`tags`; add `category_id` as a UUID string (e.g. "d4c3b2a1-..."), `category` as
a short human-readable string (e.g. "office supplies"), and `tags` as a list of
strings (e.g. ["urgent","quarterly"]) so the documented example reflects those
schema attributes.

In `@lib/ksef_hub/invoices/tag.ex`:
- Around line 12-19: The Tag schema's many_to_many association uses a string
join table; change the many_to_many :invoices association in
KsefHub.Invoices.Tag (schema "tags") to use the join_through module
KsefHub.Invoices.InvoiceTag instead of "invoice_tags" so it matches the Invoice
schema and allows Ecto to apply InvoiceTag validations/constraints when managing
associations from either side.

In `@test/ksef_hub_web/controllers/api/tag_controller_test.exs`:
- Around line 91-113: Add a test in the "update" describe block that asserts
validation errors return 422: using create_owner_with_token() to get company and
token, insert(:tag, company: company) to create the tag, call api_conn(token) |>
put("/api/tags/#{tag.id}", body) with a JSON body where name is "" and assert
conn.status == 422; place it alongside the existing "updates a tag" and
cross-company 404 tests so update validations are covered like in the create and
CategoryController tests.

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex Outdated
Comment thread lib/ksef_hub/invoices.ex
Comment thread lib/ksef_hub/invoices/invoice_tag.ex Outdated
…d changeset

- Add binary and fallback clauses to maybe_put_list/3 for single-string
  tag_ids query params
- Validate/normalize tag_ids in add_tags and set_tags before passing to
  context — malformed input now returns 422 instead of 500
- Replace Repo.insert!/1 with Repo.insert/1 + Repo.rollback in
  set_invoice_tags so unique_constraint violations don't crash the
  transaction
- Remove invoice_id/tag_id from InvoiceTag cast — FKs are now set via
  Ecto.Changeset.change/2, matching the project's server-set-field pattern
- Use join_through module (InvoiceTag) instead of string in Tag schema
- Add category_id, category, tags to Invoice OpenAPI example
- Add missing "returns 422 for invalid update" test for TagController

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 752457a and ff375d9.

📒 Files selected for processing (6)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/invoice_tag.ex
  • lib/ksef_hub/invoices/tag.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/schemas/invoice.ex
  • test/ksef_hub_web/controllers/api/tag_controller_test.exs
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/ksef_hub/invoices/invoice_tag.ex
  • lib/ksef_hub/invoices/tag.ex
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 521-534: The current validators validate_tag_ids and
validate_category_company only check is_binary/1 and allow malformed UUIDs to
hit the DB; update those functions to use Ecto.UUID.cast/1 to validate each UUID
(e.g., return {:ok, uuid} for valid or {:error, :invalid_uuid} for any bad
value) and make the controller actions that call them (set_category /
Invoices.set_invoice_category flow shown, plus add_tags and set_tags handlers)
handle the new {:error, :invalid_uuid} case by returning
put_status(:unprocessable_entity) and a JSON error (e.g., %{error: "Invalid UUID
format"}) instead of letting the DB raise. Ensure you reference
validate_tag_ids, validate_category_company, Ecto.UUID.cast/1, and the
controller actions set_category, add_tags, set_tags when locating and changing
the code.

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
- validate_tag_ids now checks each element with Ecto.UUID.cast/1
  instead of just is_binary/1, rejecting malformed UUIDs early
- validate_category_company checks UUID format before querying,
  returning {:error, :invalid_uuid} for bad values
- set_category action handles :invalid_uuid with 422 response

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 649-664: The remove_tag action doesn't validate that the incoming
"tag_id" is a UUID before calling Invoices.remove_invoice_tag/2; add a UUID
format check in remove_tag (e.g., using Ecto.UUID.cast/1 or a UUID validation
helper) and if the check fails immediately return conn |>
put_status(:unprocessable_entity) |> json(%{error: "Invalid tag_id"}) instead of
calling Invoices.remove_invoice_tag(id, tag_id), otherwise proceed as before
(retain the existing invoice lookup via Invoices.get_invoice!/3 and the current
OK/:not_found branches).
- Around line 604-625: The set_tags action's with-chain doesn't handle an error
return from Invoices.set_invoice_tags/2, so a {:error, changeset} will crash;
update set_tags to match and handle that case in the else block (or add an
additional with clause) by pattern-matching {:error, changeset} from
Invoices.set_invoice_tags and returning a 422 JSON response containing the
changeset errors (e.g., render or json %{error: "Could not set tags", details:
translate_errors(changeset)}), keeping existing handling for {:error,
:invalid_tag_ids} and false from validate_tag_ids and
Invoices.tags_belong_to_company?.
- Around line 514-540: The response omits category/tags because
set_invoice_category returns an invoice without preloads; update set_category to
return a fully preloaded invoice before calling invoice_json by either (A)
changing Invoices.set_invoice_category to preload :category and :tags on the
returned struct, or (B) refetching after update using
Invoices.get_invoice_with_details!(company_id, updated.id, role:
conn.assigns[:current_role]) and pass that preloaded invoice to invoice_json;
locate references to set_category, Invoices.set_invoice_category, invoice_json
and get_invoice_with_details! to implement the preload/refetch fix.
ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ff375d9 and 514cf73.

📒 Files selected for processing (1)
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex

Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
Comment thread lib/ksef_hub_web/controllers/api/invoice_controller.ex
…oad set_category response

- remove_tag now validates tag_id with Ecto.UUID.cast before querying,
  returning 422 for malformed UUIDs
- set_tags else block now handles {:error, changeset} from
  set_invoice_tags instead of letting it crash
- set_category refetches with get_invoice_with_details! after update
  so the response includes preloaded category and tags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek

Copy link
Copy Markdown
Member Author

@coderabbitai, review

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown

@emilwojtaszek: I'll review the changes in this pull request.


🧠 Learnings used
Learnt from: CR
Repo: appunite/appunite-ksef-ex PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-15T20:12:53.394Z
Learning: Applies to lib/ksef_hub/invoices/**/*parser*.ex : Handle nested FA(3) XML structures: `Podmiot1` (seller), `Podmiot2` (buyer), `Fa` (invoice data)
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/ksef_hub_web/controllers/api/invoice_controller.ex (1)

569-573: Consider handling add_invoice_tag return values to avoid silent failures.

Line 571 uses Enum.each/2 which discards the return value from Invoices.add_invoice_tag/2. If an insertion fails (e.g., due to a database error), the failure is silently ignored and the API returns success. While duplicate tag insertions failing silently could be intentional (idempotent behavior), other failures would go unnoticed.

Consider collecting results and handling failures:

♻️ Proposed pattern to handle failures
-      Enum.each(tag_ids, fn tag_id -> Invoices.add_invoice_tag(id, tag_id) end)
-      tags = Invoices.list_invoice_tags(id)
-      json(conn, %{data: Enum.map(tags, &tag_json/1)})
+      results = Enum.map(tag_ids, fn tag_id -> Invoices.add_invoice_tag(id, tag_id) end)
+
+      case Enum.find(results, &match?({:error, _}, &1)) do
+        nil ->
+          tags = Invoices.list_invoice_tags(id)
+          json(conn, %{data: Enum.map(tags, &tag_json/1)})
+
+        {:error, changeset} ->
+          conn
+          |> put_status(:unprocessable_entity)
+          |> json(%{error: changeset_errors(changeset)})
+      end

Alternatively, if idempotent behavior is desired, use on_conflict: :nothing in the insert or document that duplicate additions are silently ignored.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex` around lines 569 -
573, The current Enum.each calling Invoices.add_invoice_tag/2 swallows failures;
change it to collect and handle results (e.g., use Enum.reduce_while or Enum.map
to run Invoices.add_invoice_tag(id, tag_id) for each tag and pattern-match
results), return an error JSON if any call returns {:error, reason}, or
alternatively make add_invoice_tag idempotent by implementing on_conflict:
:nothing in the underlying insert and document that duplicate inserts are
ignored; update the block around validate_tag_ids,
Invoices.tags_belong_to_company?, Invoices.add_invoice_tag,
Invoices.list_invoice_tags and tag_json to propagate and respond to errors
instead of silently succeeding.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@lib/ksef_hub_web/controllers/api/invoice_controller.ex`:
- Around line 569-573: The current Enum.each calling Invoices.add_invoice_tag/2
swallows failures; change it to collect and handle results (e.g., use
Enum.reduce_while or Enum.map to run Invoices.add_invoice_tag(id, tag_id) for
each tag and pattern-match results), return an error JSON if any call returns
{:error, reason}, or alternatively make add_invoice_tag idempotent by
implementing on_conflict: :nothing in the underlying insert and document that
duplicate inserts are ignored; update the block around validate_tag_ids,
Invoices.tags_belong_to_company?, Invoices.add_invoice_tag,
Invoices.list_invoice_tags and tag_json to propagate and respond to errors
instead of silently succeeding.
ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 514cf73 and 346318b.

📒 Files selected for processing (1)
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex

Duplicate tag associations are now silently ignored instead of
returning an error, so the Enum.each in add_tags can't swallow
failures — there are none to swallow. Update test to verify
idempotency (second add succeeds, count stays at 1).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
lib/ksef_hub/invoices.ex (1)

495-506: ⚠️ Potential issue | 🟡 Minor

Deduplicate tag IDs to avoid rollbacks on duplicates.
If tag_ids contains duplicates, the unique constraint will rollback the transaction even though the desired final set is valid.

✅ Suggested fix
 def set_invoice_tags(invoice_id, tag_ids) do
+  tag_ids = Enum.uniq(tag_ids)
+
   Repo.transaction(fn ->
     InvoiceTag
     |> where([it], it.invoice_id == ^invoice_id)
     |> Repo.delete_all()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 495 - 506, set_invoice_tags will fail
the transaction if tag_ids contains duplicates because insert_invoice_tag!
triggers the InvoiceTag unique constraint; deduplicate the list before inserting
to avoid unnecessary rollbacks. Modify set_invoice_tags to call
Enum.uniq(tag_ids) (or similar) and iterate over the unique IDs when calling
insert_invoice_tag! (keep deletion of existing tags and the final
list_invoice_tags(invoice_id) as-is). Ensure you reference set_invoice_tags and
insert_invoice_tag! so the change targets the right code path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/ksef_hub/invoices.ex`:
- Around line 461-470: add_invoice_tag currently inserts an InvoiceTag without
verifying the tag belongs to the same company as the invoice (same for
set_invoice_tags/2); update both functions to first load the invoice (by
invoice_id) and the Tag (by tag_id), compare invoice.company_id and
tag.company_id, and if they differ return {:error, changeset} (add an error on
the InvoiceTag changeset or a new changeset via InvoiceTag.changeset) so the API
shape remains {:ok, InvoiceTag.t()} | {:error, Ecto.Changeset.t()}; only call
Repo.insert(on_conflict: :nothing) when the company_ids match. Ensure you keep
using InvoiceTag.changeset and preserve idempotency behavior.
- Around line 450-456: In set_invoice_category, validate that category_id is
either nil or belongs to the same company as the invoice before calling
Invoice.category_changeset and Repo.update: if category_id is nil allow proceed,
otherwise query Repo.get_by(Category, id: category_id, company_id:
invoice.company_id) (or Repo.exists?/1) and return an {:error, ...} (or an
Ecto.Changeset with an added error on :category_id) when the lookup fails; only
call Invoice.category_changeset and Repo.update when the category belongs to
invoice.company_id. Use the existing Invoice, Invoice.category_changeset, Repo,
and Category identifiers to locate where to perform this guard.

---

Duplicate comments:
In `@lib/ksef_hub/invoices.ex`:
- Around line 495-506: set_invoice_tags will fail the transaction if tag_ids
contains duplicates because insert_invoice_tag! triggers the InvoiceTag unique
constraint; deduplicate the list before inserting to avoid unnecessary
rollbacks. Modify set_invoice_tags to call Enum.uniq(tag_ids) (or similar) and
iterate over the unique IDs when calling insert_invoice_tag! (keep deletion of
existing tags and the final list_invoice_tags(invoice_id) as-is). Ensure you
reference set_invoice_tags and insert_invoice_tag! so the change targets the
right code path.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 346318b and 64e20f6.

📒 Files selected for processing (2)
  • lib/ksef_hub/invoices.ex
  • test/ksef_hub/invoices/tags_test.exs
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/ksef_hub/invoices/tags_test.exs

Comment thread lib/ksef_hub/invoices.ex Outdated
Comment thread lib/ksef_hub/invoices.ex
Comment on lines +461 to +470
@doc """
Adds a tag to an invoice. Idempotent — duplicate associations are silently ignored.
"""
@spec add_invoice_tag(Ecto.UUID.t(), Ecto.UUID.t()) ::
{:ok, InvoiceTag.t()} | {:error, Ecto.Changeset.t()}
def add_invoice_tag(invoice_id, tag_id) do
invoice_id
|> InvoiceTag.changeset(tag_id)
|> Repo.insert(on_conflict: :nothing)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Validate tag ownership when associating to invoices.
This can link a tag from another company to the invoice. Please verify the tag belongs to the invoice’s company (and apply the same check in set_invoice_tags/2).

🔒 Suggested guard (keeps {:error, changeset} shape)
 def add_invoice_tag(invoice_id, tag_id) do
-  invoice_id
-  |> InvoiceTag.changeset(tag_id)
-  |> Repo.insert(on_conflict: :nothing)
+  with %Invoice{company_id: company_id} <- Repo.get(Invoice, invoice_id),
+       true <- tags_belong_to_company?([tag_id], company_id) do
+    invoice_id
+    |> InvoiceTag.changeset(tag_id)
+    |> Repo.insert(on_conflict: :nothing)
+  else
+    _ ->
+      invoice_id
+      |> InvoiceTag.changeset(tag_id)
+      |> Ecto.Changeset.add_error(:tag_id, "does not belong to company")
+      |> then(&{:error, &1})
+  end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices.ex` around lines 461 - 470, add_invoice_tag currently
inserts an InvoiceTag without verifying the tag belongs to the same company as
the invoice (same for set_invoice_tags/2); update both functions to first load
the invoice (by invoice_id) and the Tag (by tag_id), compare invoice.company_id
and tag.company_id, and if they differ return {:error, changeset} (add an error
on the InvoiceTag changeset or a new changeset via InvoiceTag.changeset) so the
API shape remains {:ok, InvoiceTag.t()} | {:error, Ecto.Changeset.t()}; only
call Repo.insert(on_conflict: :nothing) when the company_ids match. Ensure you
keep using InvoiceTag.changeset and preserve idempotency behavior.

- set_invoice_category verifies category belongs to invoice's company
- add_invoice_tag verifies tag belongs to invoice's company
- set_invoice_tags deduplicates tag_ids with Enum.uniq before inserting
- Controller handles new :category_not_in_company error from context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek

Copy link
Copy Markdown
Member Author

@coderabbitai make final review

@coderabbitai

coderabbitai Bot commented Feb 23, 2026

Copy link
Copy Markdown

@emilwojtaszek: I'll perform a comprehensive final review of all the changes in this pull request, covering the implementation of invoice categories and tags functionality.


🧠 Learnings used
Learnt from: CR
Repo: appunite/appunite-ksef-ex PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-15T20:12:53.394Z
Learning: Applies to lib/ksef_hub/invoices/**/*parser*.ex : Handle nested FA(3) XML structures: `Podmiot1` (seller), `Podmiot2` (buyer), `Fa` (invoice data)

Learnt from: CR
Repo: appunite/appunite-ksef-ex PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-15T20:12:53.394Z
Learning: Applies to lib/ksef_hub/invoices/**/*parser*.ex : Maintain clear mapping from FA(3) Polish field codes (e.g., `P_1`, `P_2`) to domain field names when parsing XML

Learnt from: CR
Repo: appunite/appunite-ksef-ex PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-15T20:12:53.394Z
Learning: Applies to test/ksef_hub/invoices/**/*parser*_test.exs : Test FA(3) XML parsing with real-world samples covering edge cases (missing fields, multiple line items, different date formats)
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

> [!CAUTION]

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/ksef_hub/invoices/invoice.ex (1)

49-91: ⚠️ Potential issue | 🟠 Major

Prevent cross‑company category assignment via the generic changeset.

changeset/2 now casts :category_id, but it only enforces a foreign key. Any API path using this changeset can associate an invoice to a category from another company (if the UUID is known), bypassing the company check enforced in set_invoice_category/2. This is a multi‑tenant authorization risk.

Safer options:

  • Remove :category_id from the generic changeset and require set_invoice_category/2 for assignment, or
  • Add an explicit validation that the category belongs to invoice.company_id before accepting it.
🛠️ Suggested fix (require set_invoice_category/2 for category assignment)
   |> cast(attrs, [
     :ksef_number,
     :type,
     :xml_content,
     :seller_nip,
     :seller_name,
     :buyer_nip,
     :buyer_name,
     :invoice_number,
     :issue_date,
     :net_amount,
     :vat_amount,
     :gross_amount,
     :currency,
     :status,
     :source,
     :duplicate_of_id,
     :duplicate_status,
     :ksef_acquisition_date,
-    :permanent_storage_date,
-    :category_id
+    :permanent_storage_date
   ])
@@
-  |> foreign_key_constraint(:category_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub/invoices/invoice.ex` around lines 49 - 91, The changeset/2
currently casts :category_id which allows cross-company assignment; either
remove :category_id from the cast/validation pipeline so category can only be
set via set_invoice_category/2, and also remove or move the
foreign_key_constraint(:category_id) out of changeset/2 into the dedicated
setter, OR keep :category_id but add an explicit validation (e.g.
validate_category_belongs_to_company/1 called from changeset/2) that loads the
Category by the supplied :category_id and verifies its company_id matches
invoice.company_id (fail the changeset if not); update references to changeset/2
and set_invoice_category/2 accordingly.
🧹 Nitpick comments (2)
lib/ksef_hub_web/controllers/api/tag_controller.ex (1)

162-175: Handle potential delete failure gracefully.

The pattern match {:ok, _} = Invoices.delete_tag(tag) will crash if the delete fails (e.g., due to a database constraint). While unlikely for tags, defensive error handling would be more robust.

♻️ Suggested defensive error handling
 def delete(conn, %{"id" => id}) do
   company_id = conn.assigns.current_company.id

   case Invoices.get_tag(company_id, id) do
     {:ok, tag} ->
-      {:ok, _} = Invoices.delete_tag(tag)
-      json(conn, %{message: "Tag deleted"})
+      case Invoices.delete_tag(tag) do
+        {:ok, _} ->
+          json(conn, %{message: "Tag deleted"})
+
+        {:error, _changeset} ->
+          conn
+          |> put_status(:unprocessable_entity)
+          |> json(%{error: "Failed to delete tag"})
+      end

     {:error, :not_found} ->
       conn
       |> put_status(:not_found)
       |> json(%{error: "Tag not found"})
   end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/tag_controller.ex` around lines 162 - 175,
In delete/2 (TagController) the pattern match {:ok, _} =
Invoices.delete_tag(tag) will crash on failure; change it to handle both {:ok,
_} and {:error, reason} returned by Invoices.delete_tag, returning json(conn,
%{message: "Tag deleted"}) on success and responding with an appropriate error
status (e.g., 500) and json body including the reason or a generic error message
on failure; reference the Invoices.delete_tag/1 call and the delete(conn, %{"id"
=> id}) action when making the change.
lib/ksef_hub_web/controllers/api/category_controller.ex (1)

161-176: Handle potential delete failure gracefully.

Same pattern as TagController — the match {:ok, _} = Invoices.delete_category(category) will crash if delete fails. Consider defensive handling.

♻️ Suggested defensive error handling
 def delete(conn, %{"id" => id}) do
   company_id = conn.assigns.current_company.id

   case Invoices.get_category(company_id, id) do
     {:ok, category} ->
-      {:ok, _} = Invoices.delete_category(category)
-      json(conn, %{message: "Category deleted"})
+      case Invoices.delete_category(category) do
+        {:ok, _} ->
+          json(conn, %{message: "Category deleted"})
+
+        {:error, _changeset} ->
+          conn
+          |> put_status(:unprocessable_entity)
+          |> json(%{error: "Failed to delete category"})
+      end

     {:error, :not_found} ->
       conn
       |> put_status(:not_found)
       |> json(%{error: "Category not found"})
   end
 end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/ksef_hub_web/controllers/api/category_controller.ex` around lines 161 -
176, The delete/2 action in CategoryController currently pattern-matches {:ok,
_} = Invoices.delete_category(category) which will crash on failure; change it
to handle both {:ok, _} and {:error, reason} from
Invoices.delete_category(category) (in function delete/2) and return json(conn,
%{message: "Category deleted"}) on success, or set an appropriate status (e.g.,
put_status(:internal_server_error)) and return json(conn, %{error: "Failed to
delete category", details: reason}) on error so deletion failures are handled
gracefully.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@lib/ksef_hub/invoices/invoice.ex`:
- Around line 49-91: The changeset/2 currently casts :category_id which allows
cross-company assignment; either remove :category_id from the cast/validation
pipeline so category can only be set via set_invoice_category/2, and also remove
or move the foreign_key_constraint(:category_id) out of changeset/2 into the
dedicated setter, OR keep :category_id but add an explicit validation (e.g.
validate_category_belongs_to_company/1 called from changeset/2) that loads the
Category by the supplied :category_id and verifies its company_id matches
invoice.company_id (fail the changeset if not); update references to changeset/2
and set_invoice_category/2 accordingly.

---

Nitpick comments:
In `@lib/ksef_hub_web/controllers/api/category_controller.ex`:
- Around line 161-176: The delete/2 action in CategoryController currently
pattern-matches {:ok, _} = Invoices.delete_category(category) which will crash
on failure; change it to handle both {:ok, _} and {:error, reason} from
Invoices.delete_category(category) (in function delete/2) and return json(conn,
%{message: "Category deleted"}) on success, or set an appropriate status (e.g.,
put_status(:internal_server_error)) and return json(conn, %{error: "Failed to
delete category", details: reason}) on error so deletion failures are handled
gracefully.

In `@lib/ksef_hub_web/controllers/api/tag_controller.ex`:
- Around line 162-175: In delete/2 (TagController) the pattern match {:ok, _} =
Invoices.delete_tag(tag) will crash on failure; change it to handle both {:ok,
_} and {:error, reason} returned by Invoices.delete_tag, returning json(conn,
%{message: "Tag deleted"}) on success and responding with an appropriate error
status (e.g., 500) and json body including the reason or a generic error message
on failure; reference the Invoices.delete_tag/1 call and the delete(conn, %{"id"
=> id}) action when making the change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8f22f8c and c74efd0.

📒 Files selected for processing (31)
  • lib/ksef_hub/invoices.ex
  • lib/ksef_hub/invoices/category.ex
  • lib/ksef_hub/invoices/invoice.ex
  • lib/ksef_hub/invoices/invoice_tag.ex
  • lib/ksef_hub/invoices/tag.ex
  • lib/ksef_hub_web/controllers/api/category_controller.ex
  • lib/ksef_hub_web/controllers/api/invoice_controller.ex
  • lib/ksef_hub_web/controllers/api/tag_controller.ex
  • lib/ksef_hub_web/json_helpers.ex
  • lib/ksef_hub_web/router.ex
  • lib/ksef_hub_web/schemas/category.ex
  • lib/ksef_hub_web/schemas/category_list_response.ex
  • lib/ksef_hub_web/schemas/category_response.ex
  • lib/ksef_hub_web/schemas/create_category_request.ex
  • lib/ksef_hub_web/schemas/create_tag_request.ex
  • lib/ksef_hub_web/schemas/invoice.ex
  • lib/ksef_hub_web/schemas/invoice_tags_request.ex
  • lib/ksef_hub_web/schemas/set_category_request.ex
  • lib/ksef_hub_web/schemas/tag.ex
  • lib/ksef_hub_web/schemas/tag_list_response.ex
  • lib/ksef_hub_web/schemas/tag_response.ex
  • lib/ksef_hub_web/schemas/update_category_request.ex
  • lib/ksef_hub_web/schemas/update_tag_request.ex
  • priv/repo/migrations/20260223063931_create_categories.exs
  • priv/repo/migrations/20260223063932_create_tags.exs
  • test/ksef_hub/invoices/categories_test.exs
  • test/ksef_hub/invoices/tags_test.exs
  • test/ksef_hub_web/controllers/api/category_controller_test.exs
  • test/ksef_hub_web/controllers/api/invoice_controller_test.exs
  • test/ksef_hub_web/controllers/api/tag_controller_test.exs
  • test/support/factory.ex

…gracefully

- Remove :category_id from changeset/2 cast list so categories can only
  be assigned via set_invoice_category/2 which validates company ownership
- Remove dead foreign_key_constraint(:category_id) from changeset/2
  (already present in category_changeset/2)
- Handle delete failures gracefully in CategoryController and
  TagController instead of crashing on MatchError

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 9db7438 into main Feb 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant