Skip to content

Feat/manual invoice creation#47

Merged
emilwojtaszek merged 17 commits into
mainfrom
feat/manual-invoice-creation
Feb 23, 2026
Merged

Feat/manual invoice creation#47
emilwojtaszek merged 17 commits into
mainfrom
feat/manual-invoice-creation

Conversation

@emilwojtaszek

@emilwojtaszek emilwojtaszek commented Feb 23, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Manual invoice creation via REST (new create endpoint) with source tracking; OpenAPI schema updated. Added confirm/dismiss endpoints for suspected duplicates; duplicate metadata exposed (source, duplicate_of_id, duplicate_status).
  • Bug Fixes / Validation

    • xml/html/pdf endpoints return 422 when XML is missing; XML requirement is now source-dependent.
  • Chores

    • Database uniqueness adjusted to allow non-destructive duplicate handling (partial unique index).
  • Tests

    • Expanded coverage for manual entry, duplicate lifecycle, filters, rendering, and access controls.

emilwojtaszek and others added 11 commits February 23, 2026 00:46
Add migration for source, duplicate_of_id, duplicate_status columns.
Make xml_content nullable. Replace unique index with partial index
that only enforces uniqueness for non-duplicate invoices.

Update Invoice schema with new fields, belongs_to association,
source-conditional validation, and duplicate changeset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add create_manual_invoice/2 that forces source: "manual" and auto-detects
duplicates when ksef_number matches an existing invoice.

Add confirm_duplicate/1 and dismiss_duplicate/1 for managing duplicate status.
Add source filter to apply_filters/2. Update get_invoice_by_ksef_number to
exclude duplicates. Update upsert to use partial index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add create action for manual invoice creation with OpenAPI spec.
Add confirm-duplicate and dismiss-duplicate POST endpoints.
Add source filter param to index operation.
Handle nil xml_content in xml/html/pdf endpoints (return 422).
Update invoice JSON response with source, duplicate_of_id, duplicate_status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update factory with source field and manual_invoice_factory.
Add context tests for create_manual_invoice, confirm/dismiss_duplicate,
source filter, and source validation.
Add controller tests for create, confirm-duplicate, dismiss-duplicate,
nil xml_content handling, and source filter endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Documents the design for manual invoice creation with duplicate detection,
partial unique index strategy, and duplicate review workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add :source filter to list_invoices @doc.
Add 422 response to xml/html/pdf OpenAPI operation specs
for invoices without XML content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
confirm_duplicate now only works on "suspected" status.
dismiss_duplicate works on "suspected" or "confirmed" status.
Both return {:error, :invalid_status} for invalid transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When concurrent requests both miss the duplicate check but one fails
on the unique constraint, retry by looking up the original and marking
the new invoice as a suspected duplicate.

Extract find_original_id/2 for reuse in both detect_duplicate and
the retry path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add :source to upsert_replace_fields so KSeF sync overwrites
source: "manual" -> "ksef" when the same ksef_number is synced.

Add has_many :duplicates inverse association to Invoice schema
for listing all duplicates of a given invoice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…, and nil xml_content

- Fix upsert override test (backdate inserted_at for reliable insert/update detection)
- Add context tests for confirm/dismiss invalid status transitions
- Add context test for income type manual invoice
- Add controller tests for html/pdf with nil xml_content (422)
- Add controller tests for confirm/dismiss invalid status transitions

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 API support for manual invoice creation with source tracking, non‑destructive duplicate detection by ksef_number (suspected/confirmed/dismissed), endpoints to confirm/dismiss duplicates, schema and OpenAPI updates, a migration to allow nullable XML and a partial unique index, and extensive tests and factories.

Changes

Cohort / File(s) Summary
Architecture & ADR
docs/adr/0018-manual-invoice-creation.md
New ADR describing manual invoice API, duplicate-detection workflow, partial unique index, and source-dependent XML requirements.
Schema & Model
lib/ksef_hub/invoices/invoice.ex
Adds source, duplicate_of_id, duplicate_status, self-referential association and has_many :duplicates; conditional source-based validations; duplicate_changeset/2; adjusts unique constraint name and xml_content requirement.
Context / Business Logic
lib/ksef_hub/invoices.ex
Adds create_manual_invoice/2, confirm_duplicate/1, dismiss_duplicate/1; duplicate detection helpers (detect_duplicate/2, find_original_id/2, unique_ksef_number_conflict?/1); includes :source in upsert/filters and adjusts upsert conflict target handling.
API: Controller & Routing
lib/ksef_hub_web/controllers/api/invoice_controller.ex, lib/ksef_hub_web/router.ex
Adds create, confirm_duplicate, dismiss_duplicate endpoints and router entries; source filter for index; controller guards returning 422 for missing XML on html/xml/pdf; includes source, duplicate_of_id, duplicate_status in JSON.
OpenAPI / Schemas
lib/ksef_hub_web/schemas/create_invoice_request.ex, lib/ksef_hub_web/schemas/invoice.ex
New CreateInvoiceRequest schema for manual invoices; Invoice schema extended with source, duplicate_of_id, and duplicate_status properties and examples.
DB Migration
priv/repo/migrations/20260223000001_add_source_and_duplicates_to_invoices.exs
Adds source (default "ksef"), duplicate_of_id, duplicate_status; makes xml_content nullable; replaces strict unique index with a partial unique index where duplicate_of_id IS NULL; adds indexes on (company_id, source) and duplicate_of_id.
Tests & Factories
test/support/factory.ex, test/ksef_hub/invoices_test.exs, test/ksef_hub_web/controllers/api/invoice_controller_test.exs
Adds manual_invoice_factory/0; expands tests for manual creation, duplicate detection/confirm/dismiss flows, source filtering, xml-missing error cases, reviewer scoping, upsert behavior, and extensive controller tests.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as InvoiceController
    participant Context as InvoicesContext
    participant Detector as DuplicateDetector
    participant DB as Database

    Client->>Controller: POST /api/invoices (manual)
    Controller->>Context: create_manual_invoice(company_id, attrs)
    Context->>Detector: detect_duplicate(company_id, ksef_number)
    Detector->>DB: SELECT invoice WHERE company_id=... AND ksef_number=... AND duplicate_of_id IS NULL
    DB-->>Detector: existing_invoice or nil

    alt Duplicate Detected
        Detector-->>Context: {true, original_id}
        Context->>DB: INSERT invoice (source="manual", duplicate_of_id=original_id, duplicate_status="suspected")
        DB-->>Context: created_invoice
        Context-->>Controller: {:ok, created_invoice}
        Controller-->>Client: 201 Created (includes duplicate_of_id, duplicate_status)
    else No Duplicate
        Detector-->>Context: {false, nil}
        Context->>DB: INSERT invoice (source="manual")
        DB-->>Context: created_invoice
        Context-->>Controller: {:ok, created_invoice}
        Controller-->>Client: 201 Created
    end
Loading
sequenceDiagram
    participant Client
    participant Controller as InvoiceController
    participant Context as InvoicesContext
    participant DB as Database

    Client->>Controller: POST /api/invoices/:id/confirm-duplicate
    Controller->>DB: GET invoice by id (scoped to company)
    DB-->>Controller: invoice (with duplicate_of_id, duplicate_status)
    Controller->>Context: confirm_duplicate(invoice)
    Context->>DB: validate duplicate_of_id present and duplicate_status == "suspected"
    alt Valid
        Context->>DB: UPDATE invoice SET duplicate_status="confirmed"
        DB-->>Context: updated_invoice
        Context-->>Controller: {:ok, updated_invoice}
        Controller-->>Client: 200 OK
    else Invalid
        Context-->>Controller: {:error, :not_a_duplicate} or {:error, :invalid_status}
        Controller-->>Client: 422 Unprocessable Entity
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Feat/multi company support #21 — overlaps on invoices schema, uniqueness/index logic and upsert/get behavior (strong code-level overlap).
  • Feat/openapi docs #23 — touches InvoiceController, OpenAPI schemas and routes; overlaps API surface changes.
  • Feat/reviewer expense only #43 — modifies context/controller list/get/upsert and filtering behavior; likely to intersect with source/duplicate filtering and conflict logic.

Poem

🐰 I hopped through rows and checked each line,
A matching number caught my eye this time.
I flagged the suspect, kept both side by side,
A human will decide which one to hide.
Hoppy changes — tidy, cautious, and kind.

🚥 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/manual invoice creation' accurately describes the main feature introduced in this changeset: enabling manual invoice creation via API.
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/manual-invoice-creation

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.

🧹 Nitpick comments (6)
lib/ksef_hub/invoices.ex (2)

177-180: Unsafe fragment for conflict_target is necessary but worth documenting.

The {:unsafe_fragment, ...} approach is required here because Ecto's conflict_target doesn't natively support partial unique indexes with WHERE clauses. This works correctly but introduces a SQL injection surface if the fragment contents were ever dynamically constructed.

Since the fragment is hardcoded, this is safe, but consider adding an inline comment explaining why unsafe_fragment is needed.

📝 Optional: Add explanatory comment
     |> Repo.insert(
       on_conflict: {:replace, `@upsert_replace_fields`},
+      # unsafe_fragment required for partial unique index with WHERE clause
       conflict_target:
         {:unsafe_fragment,
          ~s|("company_id","ksef_number") WHERE ksef_number IS NOT NULL AND duplicate_of_id IS NULL|},
       returning: true
     )
🤖 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 177 - 180, Add a brief inline comment
immediately above the conflict_target tuple that explains why {:unsafe_fragment,
~s|("company_id","ksef_number") WHERE ksef_number IS NOT NULL AND
duplicate_of_id IS NULL|} is used (Ecto does not support partial unique index
WHERE clauses in conflict_target), state that the fragment is hardcoded so it is
safe from SQL injection, and note that it must remain static if altered;
reference the conflict_target tuple in the invoices module so future maintainers
understand the rationale.

325-336: Consider explicit else clause for clarity.

The if block implicitly returns nil when ksef_number is falsy or empty. While this works correctly, an explicit else nil would make the intent clearer to readers.

♻️ Optional: Add explicit else clause
   defp find_original_id(company_id, attrs) do
     ksef_number = attrs[:ksef_number] || attrs["ksef_number"]
 
     if ksef_number && ksef_number != "" do
       Invoice
       |> where([i], i.company_id == ^company_id and i.ksef_number == ^ksef_number)
       |> where([i], is_nil(i.duplicate_of_id))
       |> select([i], i.id)
       |> Repo.one()
+    else
+      nil
     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 325 - 336, The function
find_original_id currently relies on an implicit nil return when ksef_number is
falsy; make this explicit by adding an else clause that returns nil so intent is
clearer to readers. Locate the private function find_original_id(company_id,
attrs) and modify the if ksef_number && ksef_number != "" block to include an
else nil branch, leaving the existing query (Invoice |> where ... |> Repo.one())
unchanged.
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

269-299: Consider adding test for dismissing a confirmed duplicate.

The dismiss_duplicate tests cover dismissing a suspected duplicate (success) and dismissing an already dismissed duplicate (error). However, per the domain logic in lib/ksef_hub/invoices.ex (lines 286-291), dismissing a confirmed duplicate is also a valid transition that should succeed. Adding a test for this case would improve coverage.

💚 Suggested test to add
test "dismisses a confirmed duplicate invoice", %{conn: conn} do
  %{company: company, token: token} = create_owner_with_token()
  original = insert(:invoice, ksef_number: "dismiss-confirmed", company: company)

  duplicate =
    insert(:manual_invoice,
      ksef_number: "dismiss-confirmed",
      company: company,
      duplicate_of_id: original.id,
      duplicate_status: "confirmed"
    )

  conn =
    conn |> api_conn(token) |> post("/api/invoices/#{duplicate.id}/dismiss-duplicate")

  assert conn.status == 200
  assert Jason.decode!(conn.resp_body)["data"]["duplicate_status"] == "dismissed"
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/invoice_controller_test.exs` around lines
269 - 299, Add a new test in the describe "dismiss_duplicate" block that mirrors
the existing "dismisses a suspected duplicate invoice" test but creates the
duplicate invoice with duplicate_status: "confirmed" (use
create_owner_with_token(), insert(:invoice) for original and
insert(:manual_invoice, duplicate_of_id: original.id, duplicate_status:
"confirmed") for the duplicate), POSTs to
post("/api/invoices/#{duplicate.id}/dismiss-duplicate") and asserts conn.status
== 200 and Jason.decode!(conn.resp_body)["data"]["duplicate_status"] ==
"dismissed"; place it alongside the other tests so it validates that confirmed
-> dismissed is accepted by the dismiss_duplicate endpoint.
lib/ksef_hub_web/controllers/api/invoice_controller.ex (3)

249-272: Missing @doc and @spec for public confirm_duplicate/2 action.

Per coding guidelines, every public function must have @doc documentation and @spec type specification.

📝 Proposed fix to add documentation and type spec
+  `@doc` """
+  Confirm a suspected duplicate invoice.
+
+  Marks the invoice as a confirmed duplicate of the referenced original.
+  Only invoices with duplicate_status "suspected" can be confirmed.
+  """
+  `@spec` confirm_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t()
   def confirm_duplicate(conn, %{"id" => id}) do
🤖 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 249 -
272, Add a `@doc` and `@spec` for the public controller action confirm_duplicate/2:
document its purpose, expected params (conn and a map with "id"), possible
responses, and side effects; add a typespec like `@spec`
confirm_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t() (or more specific types
if available) placed immediately above the def confirm_duplicate clause in
InvoiceController so the function is documented and has a type specification.

116-130: Missing @doc and @spec for public create/2 action.

Per coding guidelines, every public function must have @doc documentation and @spec type specification.

📝 Proposed fix to add documentation and type spec
+  `@doc` """
+  Create a manual invoice.
+
+  Creates an invoice with source "manual" for the company associated with the
+  authenticated API token. If a ksef_number is provided and matches an existing
+  invoice, the new invoice is flagged as a suspected duplicate.
+  """
+  `@spec` create(Plug.Conn.t(), map()) :: Plug.Conn.t()
   def create(conn, params) do
🤖 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 116 -
130, The public controller action create/2 is missing `@doc` and `@spec`; add a
short `@doc` describing the endpoint behavior and expected params/response, and
add a `@spec` for create(conn :: Plug.Conn.t(), params :: map()) :: Plug.Conn.t();
place these annotations immediately above the def create(conn, params)
declaration in InvoiceController (module containing create/2) so tools and
dialyzer have documentation and type info for this public function.

292-315: Missing @doc and @spec for public dismiss_duplicate/2 action.

Per coding guidelines, every public function must have @doc documentation and @spec type specification.

📝 Proposed fix to add documentation and type spec
+  `@doc` """
+  Dismiss a duplicate invoice flag.
+
+  Marks the duplicate flag as dismissed. Can be called on invoices with
+  duplicate_status "suspected" or "confirmed".
+  """
+  `@spec` dismiss_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t()
   def dismiss_duplicate(conn, %{"id" => id}) do
🤖 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 292 -
315, Add an `@doc` and `@spec` for the public controller action dismiss_duplicate/2:
document that it dismisses a duplicate invoice for the current company and
describe expected parameters/returns, and add a `@spec` like
dismiss_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t(); place the `@doc`
immediately above the def and the `@spec` above the function definition
(referencing dismiss_duplicate/2 and mentioning used helpers
Invoices.dismiss_duplicate, invoice_json/1 and changeset_errors/1 in the doc for
clarity).
🤖 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 249-272: Add a `@doc` and `@spec` for the public controller action
confirm_duplicate/2: document its purpose, expected params (conn and a map with
"id"), possible responses, and side effects; add a typespec like `@spec`
confirm_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t() (or more specific types
if available) placed immediately above the def confirm_duplicate clause in
InvoiceController so the function is documented and has a type specification.
- Around line 116-130: The public controller action create/2 is missing `@doc` and
`@spec`; add a short `@doc` describing the endpoint behavior and expected
params/response, and add a `@spec` for create(conn :: Plug.Conn.t(), params ::
map()) :: Plug.Conn.t(); place these annotations immediately above the def
create(conn, params) declaration in InvoiceController (module containing
create/2) so tools and dialyzer have documentation and type info for this public
function.
- Around line 292-315: Add an `@doc` and `@spec` for the public controller action
dismiss_duplicate/2: document that it dismisses a duplicate invoice for the
current company and describe expected parameters/returns, and add a `@spec` like
dismiss_duplicate(Plug.Conn.t(), map()) :: Plug.Conn.t(); place the `@doc`
immediately above the def and the `@spec` above the function definition
(referencing dismiss_duplicate/2 and mentioning used helpers
Invoices.dismiss_duplicate, invoice_json/1 and changeset_errors/1 in the doc for
clarity).

In `@lib/ksef_hub/invoices.ex`:
- Around line 177-180: Add a brief inline comment immediately above the
conflict_target tuple that explains why {:unsafe_fragment,
~s|("company_id","ksef_number") WHERE ksef_number IS NOT NULL AND
duplicate_of_id IS NULL|} is used (Ecto does not support partial unique index
WHERE clauses in conflict_target), state that the fragment is hardcoded so it is
safe from SQL injection, and note that it must remain static if altered;
reference the conflict_target tuple in the invoices module so future maintainers
understand the rationale.
- Around line 325-336: The function find_original_id currently relies on an
implicit nil return when ksef_number is falsy; make this explicit by adding an
else clause that returns nil so intent is clearer to readers. Locate the private
function find_original_id(company_id, attrs) and modify the if ksef_number &&
ksef_number != "" block to include an else nil branch, leaving the existing
query (Invoice |> where ... |> Repo.one()) unchanged.

In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 269-299: Add a new test in the describe "dismiss_duplicate" block
that mirrors the existing "dismisses a suspected duplicate invoice" test but
creates the duplicate invoice with duplicate_status: "confirmed" (use
create_owner_with_token(), insert(:invoice) for original and
insert(:manual_invoice, duplicate_of_id: original.id, duplicate_status:
"confirmed") for the duplicate), POSTs to
post("/api/invoices/#{duplicate.id}/dismiss-duplicate") and asserts conn.status
== 200 and Jason.decode!(conn.resp_body)["data"]["duplicate_status"] ==
"dismissed"; place it alongside the other tests so it validates that confirmed
-> dismissed is accepted by the dismiss_duplicate endpoint.

emilwojtaszek and others added 2 commits February 23, 2026 01:24
- Add inline comment explaining {:unsafe_fragment, ...} conflict_target
- Add explicit else nil to find_original_id for clarity
- Add controller test for confirmed -> dismissed transition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Consistently annotate every public controller action with a concise
@doc string and @SPEC. Replaces the verbose xml/2 docstring with the
same one-liner style used across all other actions.

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.

🧹 Nitpick comments (2)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

421-433: Consider adding symmetric test for source=ksef filter.

The test verifies filtering by source=manual works. Adding a test for source=ksef would ensure both filter values work correctly.

✨ Optional: Add test for ksef source filter
test "filters invoices by source=ksef in index", %{conn: conn} do
  %{company: company, token: token} = create_owner_with_token()
  insert(:invoice, company: company, source: "ksef")
  insert(:manual_invoice, company: company, source: "manual")

  conn = conn |> api_conn(token) |> get("/api/invoices?source=ksef")

  body = Jason.decode!(conn.resp_body)
  assert length(body["data"]) == 1
  assert hd(body["data"])["source"] == "ksef"
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/invoice_controller_test.exs` around lines
421 - 433, Add a symmetric test to cover the "ksef" source path: duplicate the
existing test in the describe "source filter" block (the test "filters invoices
by source in index" that uses create_owner_with_token, insert(:invoice) and
insert(:manual_invoice)) and change the query to
get("/api/invoices?source=ksef") and assertions to expect one result with
hd(body["data"])["source"] == "ksef"; place the new test alongside the existing
one to ensure both source=manual and source=ksef filters are validated.
lib/ksef_hub/invoices.ex (1)

232-257: Consider wrapping in a transaction for atomicity.

The current flow has a TOCTOU window: between detect_duplicate finding no original and create_invoice executing, another process could insert an invoice with the same ksef_number. While the retry logic on unique constraint violation mitigates this, wrapping the operation in Repo.transaction/1 would provide stronger guarantees and clearer intent.

The current defensive approach (catching constraint violations and retrying) does work, but:

  1. The find_original_id/2 call on line 249 could also race if the conflicting record is deleted.
  2. The retry path duplicates the duplicate-marking logic.
♻️ Optional: Consider using a transaction
 def create_manual_invoice(company_id, attrs) do
+  Repo.transaction(fn ->
     attrs =
       attrs
       |> Map.drop([:ksef_acquisition_date, :permanent_storage_date])
       |> Map.drop(["ksef_acquisition_date", "permanent_storage_date"])
       |> Map.merge(%{source: "manual", company_id: company_id})

     attrs = detect_duplicate(company_id, attrs)

     case create_invoice(attrs) do
       {:ok, invoice} ->
-        {:ok, invoice}
+        invoice

       {:error, %Ecto.Changeset{} = changeset} ->
         if unique_ksef_number_conflict?(changeset) do
-          attrs
-          |> Map.merge(%{
-            duplicate_of_id: find_original_id(company_id, attrs),
-            duplicate_status: "suspected"
-          })
-          |> create_invoice()
+          case attrs
+               |> Map.merge(%{
+                 duplicate_of_id: find_original_id(company_id, attrs),
+                 duplicate_status: "suspected"
+               })
+               |> create_invoice() do
+            {:ok, invoice} -> invoice
+            {:error, changeset} -> Repo.rollback(changeset)
+          end
         else
-          {:error, changeset}
+          Repo.rollback(changeset)
         end
     end
+  end)
 end

As per coding guidelines: "Use Ecto transactions and unique constraints for atomicity in database operations"

🤖 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 232 - 257, Wrap the whole
duplicate-detection-and-insert sequence in a single Repo.transaction: call
detect_duplicate(company_id, attrs) inside the transaction, attempt
create_invoice(attrs) there, and if you get {:error, %Ecto.Changeset{}} and
unique_ksef_number_conflict?(changeset) then re-query the original (via
find_original_id(company_id, attrs)) and perform the duplicate-marking update
(setting duplicate_of_id and duplicate_status) inside the same transaction
before retrying/returning; ensure create_manual_invoice uses Repo.transaction(fn
-> ... end) so the detection, insert, re-query and duplicate update are atomic
and avoid the TOCTOU race.
🤖 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/invoices.ex`:
- Around line 232-257: Wrap the whole duplicate-detection-and-insert sequence in
a single Repo.transaction: call detect_duplicate(company_id, attrs) inside the
transaction, attempt create_invoice(attrs) there, and if you get {:error,
%Ecto.Changeset{}} and unique_ksef_number_conflict?(changeset) then re-query the
original (via find_original_id(company_id, attrs)) and perform the
duplicate-marking update (setting duplicate_of_id and duplicate_status) inside
the same transaction before retrying/returning; ensure create_manual_invoice
uses Repo.transaction(fn -> ... end) so the detection, insert, re-query and
duplicate update are atomic and avoid the TOCTOU race.

In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 421-433: Add a symmetric test to cover the "ksef" source path:
duplicate the existing test in the describe "source filter" block (the test
"filters invoices by source in index" that uses create_owner_with_token,
insert(:invoice) and insert(:manual_invoice)) and change the query to
get("/api/invoices?source=ksef") and assertions to expect one result with
hd(body["data"])["source"] == "ksef"; place the new test alongside the existing
one to ensure both source=manual and source=ksef filters are validated.

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.

🧹 Nitpick comments (1)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

237-318: Consider adding cross-company scoping tests for duplicate endpoints.

The existing approve, reject, and xml endpoints all include tests verifying that accessing invoices from a different company returns 404. For consistency and to ensure proper authorization, consider adding similar tests for confirm_duplicate and dismiss_duplicate.

🧪 Example test for cross-company scoping
test "returns 404 when confirming duplicate from different company", %{conn: conn} do
  %{token: token} = create_owner_with_token()
  other_company = insert(:company)
  original = insert(:invoice, ksef_number: "other-company", company: other_company)

  duplicate =
    insert(:manual_invoice,
      ksef_number: "other-company",
      company: other_company,
      duplicate_of_id: original.id,
      duplicate_status: "suspected"
    )

  assert_error_sent 404, fn ->
    conn |> api_conn(token) |> post("/api/invoices/#{duplicate.id}/confirm-duplicate")
  end
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/invoice_controller_test.exs` around lines
237 - 318, Add cross-company scoping tests for the confirm_duplicate and
dismiss_duplicate endpoints: create a different company and its original and
manual duplicate invoices (with duplicate_status "suspected"), then call POST
"/api/invoices/:id/confirm-duplicate" and POST
"/api/invoices/:id/dismiss-duplicate" using a token from another company and
assert a 404 is raised (use assert_error_sent 404). Reference the existing test
names and endpoints (confirm_duplicate, dismiss_duplicate) and mirror the
pattern used in approve/reject/xml cross-company tests.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 237-318: Add cross-company scoping tests for the confirm_duplicate
and dismiss_duplicate endpoints: create a different company and its original and
manual duplicate invoices (with duplicate_status "suspected"), then call POST
"/api/invoices/:id/confirm-duplicate" and POST
"/api/invoices/:id/dismiss-duplicate" using a token from another company and
assert a 404 is raised (use assert_error_sent 404). Reference the existing test
names and endpoints (confirm_duplicate, dismiss_duplicate) and mirror the
pattern used in approve/reject/xml cross-company tests.

Mirrors the existing 404 pattern from approve/reject/xml to ensure
confirm_duplicate and dismiss_duplicate reject invoices from other companies.

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.

🧹 Nitpick comments (1)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

199-223: Consider adding a test for cross-company duplicate non-detection.

The test verifies that duplicate detection works when ksef_number matches an invoice within the same company. However, there's no test verifying that an invoice in a different company with the same ksef_number does not trigger duplicate detection. This edge case would confirm company-scoped deduplication works correctly.

💡 Suggested test case
test "does not detect duplicate when ksef_number matches invoice in different company", %{conn: conn} do
  %{token: token} = create_owner_with_token()
  other_company = insert(:company)
  _other_invoice = insert(:invoice, ksef_number: "cross-company-123", company: other_company)

  body =
    Jason.encode!(%{
      type: "expense",
      ksef_number: "cross-company-123",
      seller_nip: "1234567890",
      seller_name: "Seller Sp. z o.o.",
      buyer_nip: "0987654321",
      buyer_name: "Buyer S.A.",
      invoice_number: "FV/2026/003",
      issue_date: "2026-02-20",
      net_amount: "1000.00",
      gross_amount: "1230.00"
    })

  conn = conn |> api_conn(token) |> post("/api/invoices", body)

  assert conn.status == 201
  data = Jason.decode!(conn.resp_body)["data"]
  assert is_nil(data["duplicate_of_id"])
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/invoice_controller_test.exs` around lines
199 - 223, Add a new test that ensures duplicate detection is scoped to a
company: use create_owner_with_token() to get a token for the request-owning
company, insert(:company) to create a different company and insert(:invoice,
ksef_number: "cross-company-123", company: other_company) there, then build a
request body with ksef_number "cross-company-123", call conn |> api_conn(token)
|> post("/api/invoices", body) and assert conn.status == 201 and that
Jason.decode!(conn.resp_body)["data"]["duplicate_of_id"] is nil; place this
alongside the existing "creates manual invoice and detects duplicate by
ksef_number" test so it exercises the same controller paths used by the
duplicate logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 199-223: Add a new test that ensures duplicate detection is scoped
to a company: use create_owner_with_token() to get a token for the
request-owning company, insert(:company) to create a different company and
insert(:invoice, ksef_number: "cross-company-123", company: other_company)
there, then build a request body with ksef_number "cross-company-123", call conn
|> api_conn(token) |> post("/api/invoices", body) and assert conn.status == 201
and that Jason.decode!(conn.resp_body)["data"]["duplicate_of_id"] is nil; place
this alongside the existing "creates manual invoice and detects duplicate by
ksef_number" test so it exercises the same controller paths used by the
duplicate logic.

Ensures that creating a manual invoice with a ksef_number matching an
invoice from a different company does not flag it as a duplicate.

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.

🧹 Nitpick comments (1)
test/ksef_hub_web/controllers/api/invoice_controller_test.exs (1)

382-416: Consider consolidating similar test blocks.

These three describe blocks test the same behavior (422 for nil xml_content) across three endpoints. While the current separation is acceptable, you could optionally consolidate them using a parameterized approach to reduce duplication.

♻️ Optional: Parameterized test approach
describe "content endpoints with nil xml_content" do
  for endpoint <- ["xml", "html", "pdf"] do
    test "#{endpoint} returns 422 for invoice without xml_content", %{conn: conn} do
      %{company: company, token: token} = create_owner_with_token()
      invoice = insert(:manual_invoice, company: company)

      conn = conn |> api_conn(token) |> get("/api/invoices/#{invoice.id}/#{unquote(endpoint)}")

      assert conn.status == 422
      assert Jason.decode!(conn.resp_body)["error"] == "Invoice has no XML content"
    end
  end
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/invoice_controller_test.exs` around lines
382 - 416, The three separate describe blocks testing 422 for nil xml_content
(tests named "returns 422 for invoice without xml_content" that call
get("/api/invoices/#{invoice.id}/xml"), get("/api/invoices/#{invoice.id}/html"),
get("/api/invoices/#{invoice.id}/pdf")) should be consolidated into a single
parameterized describe to remove duplication: create one describe (e.g.,
"content endpoints with nil xml_content") and loop over the endpoints
["xml","html","pdf"], reusing the existing setup calls
create_owner_with_token(), insert(:manual_invoice, company: company) and
api_conn(token), and make the GET against
"/api/invoices/#{invoice.id}/#{endpoint}" (use unquote(endpoint) in the test
body) while asserting status 422 and the same JSON error; keep existing helper
calls and assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@test/ksef_hub_web/controllers/api/invoice_controller_test.exs`:
- Around line 382-416: The three separate describe blocks testing 422 for nil
xml_content (tests named "returns 422 for invoice without xml_content" that call
get("/api/invoices/#{invoice.id}/xml"), get("/api/invoices/#{invoice.id}/html"),
get("/api/invoices/#{invoice.id}/pdf")) should be consolidated into a single
parameterized describe to remove duplication: create one describe (e.g.,
"content endpoints with nil xml_content") and loop over the endpoints
["xml","html","pdf"], reusing the existing setup calls
create_owner_with_token(), insert(:manual_invoice, company: company) and
api_conn(token), and make the GET against
"/api/invoices/#{invoice.id}/#{endpoint}" (use unquote(endpoint) in the test
body) while asserting status 422 and the same JSON error; keep existing helper
calls and assertions unchanged.

Replace three identical describe blocks (xml/html/pdf) with a single
compile-time loop over endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@emilwojtaszek emilwojtaszek merged commit 8f22f8c into main Feb 23, 2026
3 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