Skip to content

feat: v3 invoice update endpoint#4639

Merged
borosr merged 2 commits into
mainfrom
feat/v3-invoice-api-update
Jul 6, 2026
Merged

feat: v3 invoice update endpoint#4639
borosr merged 2 commits into
mainfrom
feat/v3-invoice-api-update

Conversation

@borosr

@borosr borosr commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds a v3 (AIP) endpoint to update billing invoices: PUT /openmeter/billing/invoices/{invoiceId} (update-invoice). Only the invoice's mutable fields are editable — description, labels, supplier, customer snapshot, workflow settings (invoicing auto-advance/draft period, payment collection method/due date), and top-level lines (create, edit, or delete by ID). Everything else — status, totals, currency, timestamps, detailed lines, customer/supplier IDs — stays read-only.

Why

v3 previously only exposed read access to invoices (list-invoices, get-invoice); any edit had to go through the legacy v1 API (PUT /api/v1/billing/invoices/{id}). This brings invoice mutation into v3, scoped down to a well-defined mutable-field set rather than a full v1 port, and follows the same Shared.UpsertRequest/UpdateResponse pattern already used by update-billing-profile so the v3 CRUD surface stays consistent.

Domain constraints are inherited, not reimplemented: only standard invoices are supported (v3 doesn't expose gathering invoices), and only draft invoices can be updated — both enforced by the existing billing state machine and adapter-level immutability guards (e.g. customer ID can't change even if a client echoes a different value back).

How

  • TypeSpec (api/spec/packages/aip/src/invoices/{invoice,operations}.tsp): added Lifecycle.Create, Lifecycle.Update visibility to the mutable fields, added an id?/service_period/new invoice_at field to InvoiceStandardLine plus Update visibility on rate card fields, and added the update operation.
  • Handler (api/v3/handlers/billinginvoices/): new update.go calls billing.Service.UpdateStandardInvoice with an EditFn that merges the request onto the loaded invoice; convert.go gained merge helpers (supplier/customer/workflow/lines) ported from the v1 httpdriver merge logic, explicitly preserving identity fields (customer ID, supplier ID) instead of overwriting them.
  • Wiring: new method on the Handler interface, new route delegate in api/v3/server/routes.go (handler was already instantiated, no server.go change needed).
  • Tests: 4 new converter tests covering line create/merge/tombstone-on-omit and immutable-field preservation for customer/supplier merges.

Verified via make gen-api && make generate, go build/go vet/go test on the touched packages, and a full-repo golangci-lint run — all clean.

Overview

Fixes #(issue)

Notes for reviewer

Summary by CodeRabbit

  • New Features
    • Added invoice update support to the API/SDK via PUT /openmeter/billing/invoices/{invoiceId} (draft standard invoices).
    • Updated the JavaScript SDK with updateInvoice plus new exported update request/response types for invoice workflows, pricing, tax, and invoice line edits.
    • Added a Has helper to the generic set utility.
  • Bug Fixes
    • Improved update conversion/merge behavior, including line reconciliation (match/create/delete), due-after handling, and identifier semantics.
  • Tests
    • Added conversion and end-to-end coverage for successful updates and error scenarios.

@borosr borosr requested a review from a team as a code owner July 3, 2026 10:52
@borosr borosr requested review from rolosp, tothandras and turip July 3, 2026 10:52
@borosr borosr self-assigned this Jul 3, 2026
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

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

This PR adds invoice update support across the TypeSpec contracts, generated JavaScript SDK, v3 Go handlers, and end-to-end tests. It also adds a Has lookup method to the generic set implementation.

Changes

Invoice Update Feature

Layer / File(s) Summary
TypeSpec update contract
api/spec/packages/aip/src/invoices/invoice.tsp, operations.tsp, api/spec/packages/aip/src/shared/request.tsp, resource.tsp
Defines UpdateInvoiceRequest, update-visible invoice fields, nested update visibility helpers, and the PUT update operation.
Generated models and schemas
api/spec/packages/aip-client-javascript/src/models/types.ts, schemas.ts, src/models/operations/invoices.ts
Adds update request/response types, generated schemas, and request wiring for invoice updates.
Generated client wiring and docs
api/spec/packages/aip-client-javascript/src/index.ts, src/funcs/invoices.ts, src/sdk/invoices.ts, README.md
Exports the update client operation, SDK method, and README entry for invoice update support.
Go merge and conversion logic
api/v3/handlers/billinginvoices/convert.go, convert_test.go
Adds merge helpers for invoice updates, maps update payloads into domain structures, and extends conversion tests for the new update behavior.
Go v3 handler and routing
api/v3/handlers/billinginvoices/handler.go, update.go, api/v3/server/routes.go
Adds the v3 update handler, request decoding, discriminator checks, response encoding, handler interface wiring, and server route forwarding.
End-to-end coverage
e2e/billinginvoices_v3_test.go, e2e/v3helpers_test.go
Adds the v3 client helper and end-to-end test coverage for invoice update success and error cases.

Set Membership Helper

Layer / File(s) Summary
Set lookup method
pkg/set/set.go
Adds Has to check whether an item exists in the set’s internal map.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant billingInvoicesHandler.UpdateBillingInvoice
  participant UpdateStandardInvoice

  Client->>Server: PUT /api/v3/openmeter/billing/invoices/{invoiceId}
  Server->>billingInvoicesHandler.UpdateBillingInvoice: delegate request
  billingInvoicesHandler.UpdateBillingInvoice->>UpdateStandardInvoice: merge and persist update
  UpdateStandardInvoice-->>billingInvoicesHandler.UpdateBillingInvoice: updated invoice
  billingInvoicesHandler.UpdateBillingInvoice-->>Server: JSON response
Loading

Suggested labels: kind/feature, release-note/feature

Suggested reviewers: tothandras

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding the v3 invoice update endpoint.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v3-invoice-api-update

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.

@borosr borosr added kind/feature New feature or request release-note/feature Release note: Exciting New Features labels Jul 3, 2026
@borosr borosr force-pushed the feat/v3-invoice-api-update branch from 0199f3c to 565037a Compare July 3, 2026 10:55
@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a PUT /openmeter/billing/invoices/{invoiceId} endpoint to the v3 API, enabling mutation of draft standard invoices. The update is scoped to mutable fields only (description, labels, supplier/customer snapshot, workflow settings, and top-level lines), with identity fields preserved in the merge helpers.

  • New endpoint + handler (update.go, routes.go): pre-checks invoice type to avoid calling UpdateStandardInvoice on gathering invoices, then delegates to an EditFn that calls mergeStandardInvoiceFromAPI.
  • Merge helpers (convert.go): new functions for supplier, customer, workflow, and line reconciliation; the line function matches by ID, creates on unknown/empty ID, and tombstones omitted lines.
  • TypeSpec changes: mutable-field visibility widened to Lifecycle.Update, InvoiceStandardLine.id made optional to support create-on-omit semantics, due_after added to invoicing settings (read-only) and DueAfter cleared on charge_automatically.
  • Tests: unit converter tests and an e2e suite covering update, negative-amount rejection, 404 for gathering invoices and unknown IDs, and valid line addition.

Confidence Score: 4/5

Safe to merge with one fix: the line reconciliation loop should exclude already-deleted lines before building the lookup map.

The handler, merge helpers, TypeSpec, and e2e tests are all well-structured. One real defect exists in mergeStandardInvoiceLinesFromAPI: building linesByID from all lines — including those already soft-deleted — causes every subsequent update to re-tombstone previously-deleted lines (updating their deleted_at to now) and allows silent attribute mutations on deleted rows when a client supplies a deleted line's ID. Fixing it is a one-liner filter before the UniqueGroupBy call.

api/v3/handlers/billinginvoices/convert.go — the linesByID map construction in mergeStandardInvoiceLinesFromAPI.

Important Files Changed

Filename Overview
api/v3/handlers/billinginvoices/convert.go Merge helpers for supplier, customer, workflow and lines added. DueAfter fixed with EmptyableToPtr; DueAfter cleared on charge_automatically; UsageBased nil guard in place. One concern: linesByID is built from inv.Lines.OrEmpty() which includes soft-deleted lines, causing re-tombstoning on each subsequent update.
api/v3/handlers/billinginvoices/update.go Handler correctly pre-checks invoice type to avoid passing gathering invoices to UpdateStandardInvoice; decodes discriminator, delegates to EditFn, returns updated invoice. Clean implementation with good error handling.
api/spec/packages/aip/src/invoices/invoice.tsp Correct TypeSpec changes: supplier/customer/workflow/lines fields widened to Lifecycle.Update; InvoiceStandardLine.id made optional for create-on-omit semantics; due_after added read-only to invoicing settings section.
api/v3/handlers/billinginvoices/convert_test.go Four unit tests covering tombstone-on-omit, nil-lines preservation, immutable customer field protection, and supplier merge. Well chosen scenarios.
e2e/billinginvoices_v3_test.go Comprehensive e2e suite using a manual-approval billing profile to keep the invoice in draft. Covers basic update, unsupported-type 400, gathering-invoice 404, unknown-ID 404, negative-amount 400, and valid line addition.
api/v3/handlers/billinginvoices/error_encoder.go New encodeValidationIssue encoder correctly converts billing.ValidationError chain to 400 via billing.ToValidationIssues; uses errors.Join for multi-issue responses.
pkg/set/set.go Adds Has() method with correct RLock semantics, consistent with the rest of the Set implementation.
api/v3/server/routes.go Minimal wiring: routes UpdateInvoice to the new handler, consistent with the existing GetInvoice pattern.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant Handler as update.go Handler
    participant Service as billing.Service
    participant DB

    Client->>Handler: "PUT /billing/invoices/{id}"
    Handler->>Service: GetInvoiceById(id)
    Service->>DB: SELECT invoice
    DB-->>Service: invoice (standard/gathering)
    Service-->>Handler: invoice

    alt "Invoice type != standard"
        Handler-->>Client: 404 Not Found
    end

    Handler->>Handler: Decode body discriminator
    alt "type != standard"
        Handler-->>Client: 400 Bad Request
    end

    Handler->>Service: UpdateStandardInvoice(EditFn)
    Service->>DB: SELECT invoice + lines for update
    DB-->>Service: StandardInvoice incl. soft-deleted lines
    Service->>Handler: EditFn(inv)
    Handler->>Handler: mergeStandardInvoiceFromAPI(inv, req)
    Note over Handler: merges description, labels,<br/>supplier, customer, workflow, lines
    Handler->>Handler: mergeStandardInvoiceLinesFromAPI
    Note over Handler: match by ID - merge<br/>unknown/empty ID - create<br/>omitted existing ID - tombstone
    Handler-->>Service: mutated inv
    Service->>DB: UPDATE invoice + lines
    DB-->>Service: updated invoice
    Service-->>Handler: updated StandardInvoice
    Handler->>Handler: ToAPIBillingInvoice(updated)
    Handler-->>Client: 200 BillingInvoice
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Client
    participant Handler as update.go Handler
    participant Service as billing.Service
    participant DB

    Client->>Handler: "PUT /billing/invoices/{id}"
    Handler->>Service: GetInvoiceById(id)
    Service->>DB: SELECT invoice
    DB-->>Service: invoice (standard/gathering)
    Service-->>Handler: invoice

    alt "Invoice type != standard"
        Handler-->>Client: 404 Not Found
    end

    Handler->>Handler: Decode body discriminator
    alt "type != standard"
        Handler-->>Client: 400 Bad Request
    end

    Handler->>Service: UpdateStandardInvoice(EditFn)
    Service->>DB: SELECT invoice + lines for update
    DB-->>Service: StandardInvoice incl. soft-deleted lines
    Service->>Handler: EditFn(inv)
    Handler->>Handler: mergeStandardInvoiceFromAPI(inv, req)
    Note over Handler: merges description, labels,<br/>supplier, customer, workflow, lines
    Handler->>Handler: mergeStandardInvoiceLinesFromAPI
    Note over Handler: match by ID - merge<br/>unknown/empty ID - create<br/>omitted existing ID - tombstone
    Handler-->>Service: mutated inv
    Service->>DB: UPDATE invoice + lines
    DB-->>Service: updated invoice
    Service-->>Handler: updated StandardInvoice
    Handler->>Handler: ToAPIBillingInvoice(updated)
    Handler-->>Client: 200 BillingInvoice
Loading

Reviews (7): Last reviewed commit: "fix: linter and generate error" | Re-trigger Greptile

Comment thread api/v3/handlers/billinginvoices/convert.go
Comment thread api/v3/handlers/billinginvoices/convert.go

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (1)
api/spec/packages/aip/src/shared/resource.tsp (1)

77-83: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep ResourceReference<T>.id read/create-only unless you want update payloads to grow across all consumers. This also reaches non-invoice update shapes like ProductCatalog.RateCard.feature and ProductCatalog.RateCardTaxConfig.code, so UpdateResourceReference will show up anywhere those refs are editable.

🤖 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 `@api/spec/packages/aip/src/shared/resource.tsp` around lines 77 - 83, The
`ResourceReference<T>.id` field in `ResourceReference` is currently visible for
updates, which causes `UpdateResourceReference`-based payloads to expand across
all consumers. Update the `@visibility` on `id` to keep it read/create-only, and
ensure any generated update shapes for references like
`ProductCatalog.RateCard.feature` and `ProductCatalog.RateCardTaxConfig.code` do
not include `id` as an editable field.
🧹 Nitpick comments (5)
e2e/billinginvoices_v3_test.go (2)

887-914: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Minor: TaxId isn't preserved by the supplier mapping.

Given the documented "replace, not merge" semantics for UpdateSupplier, omitting TaxId here would silently clear it if the supplier ever has one set. Currently harmless since the test profile's supplier has no tax ID, but worth a note for future-proofing if this helper gets reused with a supplier that has tax data.

🤖 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 `@e2e/billinginvoices_v3_test.go` around lines 887 - 914, The supplier
round-trip helper in updateSupplierFromInvoiceParty drops TaxId, which can
unintentionally clear an existing tax ID because UpdateSupplier replaces the
full object. Update updateSupplierFromInvoiceParty to copy the invoice party’s
TaxId into the returned apiv3.UpdateSupplier alongside Id and Name, and keep the
address mapping unchanged.

719-745: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Beef up the success-path assertions to cover the actual mutable fields.

The "Should update the standard invoice via v3 PUT" case only asserts Id and Description on the response (lines 743-744), even though the request also carries Customer, Supplier, and an always-empty Workflow: apiv3.UpdateInvoiceWorkflowSettings{}. Since workflow/supplier/customer merging is a core part of what this PR ships, a regression in mergeStandardInvoiceFromAPI's handling of those fields (e.g., accidentally resetting workflow settings when the request submits a zero-value struct) wouldn't be caught by this test. Consider asserting that the returned invoice's supplier/customer match what was sent, and that workflow settings come back as expected (even if that's "reset to defaults" — pin down the expected value).

As per path instructions, "Make sure the tests are comprehensive and cover the changes." — supplier/customer/workflow merging is one of the changes this test is meant to exercise.

✅ Example additional assertions
 		stdInv, err := updatedInv.AsBillingInvoiceStandard()
 		require.NoError(t, err)
 		assert.Equal(t, invoice.Id, stdInv.Id)
 		assert.Equal(t, newDescription, lo.FromPtr(stdInv.Description))
+		assert.Equal(t, *invoice.Customer.Name, stdInv.Customer.Name)
+		assert.Equal(t, updateSupplierFromInvoiceParty(invoice.Supplier).Name, stdInv.Supplier.Name)
🤖 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 `@e2e/billinginvoices_v3_test.go` around lines 719 - 745, The v3 PUT
success-path test in the billing invoice update case only checks Id and
Description, so it won’t catch regressions in mutable fields handled by
mergeStandardInvoiceFromAPI. Extend the assertions in the “Should update the
standard invoice via v3 PUT” test to verify the returned standard invoice’s
Customer, Supplier, and WorkflowSettings match the request/expected merged
state, using the existing updateReq, updatedInv, and AsBillingInvoiceStandard
symbols to locate the assertions.
api/spec/packages/aip/src/shared/request.tsp (1)

34-42: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Clarify UpdateRequestNested's full-object nested-replace semantics.

Unlike UpdateRequest<T> (which wraps fields in OptionalProperties), UpdateRequestNested<T> does not — so nested sub-object fields with defaults (e.g. auto_advance, draft_period on invoicing settings) become required in the generated update payload. That means updating a nested section like workflow requires supplying the whole sub-object, not a partial patch — a meaningful difference from the top-level UpdateRequest pattern that isn't documented anywhere on this model.

Worth a short docstring note explaining this full-replace-of-nested-section behavior so future readers don't assume partial-patch semantics apply here too.

As per coding guidelines: "Add docstrings to domain helpers when the name compresses important business semantics, and describe the observable business contract rather than implementation mechanics."

🤖 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 `@api/spec/packages/aip/src/shared/request.tsp` around lines 34 - 42, Add a
short docstring note on UpdateRequestNested<T> clarifying that it is a
full-replace update shape for nested sections, not a partial patch like
UpdateRequest<T>. Explain that because it omits OptionalProperties, callers
updating a nested object (for example via workflow) must supply the complete
sub-object including any defaulted fields such as auto_advance and draft_period.
Keep the note alongside the UpdateRequestNested model and reference its
relationship to FilterVisibility/DefaultKeyVisibility so readers understand the
contract.

Source: Coding guidelines

api/spec/packages/aip-client-javascript/src/models/schemas.ts (2)

1697-1702: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Generic updateResourceReference carries a hardcoded "TaxCode reference." description.

Given the stack outline calls this out as a reusable ResourceReference visibility helper, baking a TaxCode-specific description into the shared generated type is misleading if it's ever reused for a different resource kind (feature key, etc.). Worth checking resource.tsp/request.tsp (not in this batch) for whether each instantiation should carry its own contextual doc via the property, not the shared model.

🤖 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 `@api/spec/packages/aip-client-javascript/src/models/schemas.ts` around lines
1697 - 1702, The shared updateResourceReference schema is incorrectly hardcoded
with a TaxCode-specific description, which makes this reusable helper
misleading. Update the schema in updateResourceReference so it uses a generic
description or no description at all, and move any resource-specific wording to
the instantiating property/documentation in resource.tsp or request.tsp where
the reference is actually used.

4144-4145: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

array / array_2 are pretty opaque export names.

export const array = z.array(updatePriceTier) and later array_2 = z.array(updateInvoiceLine) (line 5554) are exported public symbols with no descriptive naming — likely a symptom of the TypeSpec models lacking @friendlyName on these anonymous array properties. Not a functional bug, but it's rough for anyone consuming the generated SDK directly and risks future name collisions as more anonymous arrays get generated.

🤖 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 `@api/spec/packages/aip-client-javascript/src/models/schemas.ts` around lines
4144 - 4145, The generated public export name is opaque here, so update the
schema generation around the anonymous array model used by array and array_2 to
produce descriptive symbols instead of generic names. Add or propagate a
friendly name for the underlying TypeSpec array properties so the exported
z.array(...) constants in schemas.ts get stable, meaningful identifiers, and
make the same adjustment for both the updatePriceTier and updateInvoiceLine
cases to avoid future collisions.
🤖 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 `@api/spec/packages/aip-client-javascript/src/models/types.ts`:
- Around line 3789-3790: The generated types file has a duplicate top-level
Array alias, causing a TypeScript TS2300 conflict. Update the upstream
TypeSpec/generator source for the affected model aliases (the Array exports for
UpdatePriceTier and UpdateInvoiceStandardLine) so only one unique name is
emitted, and regenerate the client instead of editing types.ts directly.

In `@api/spec/packages/aip/src/invoices/invoice.tsp`:
- Around line 344-354: Make the `id` fields read-only in update payloads because
the backend ignores them anyway: in `InvoiceCustomer` and `Supplier`, remove
`Lifecycle.Update` from the `id` visibility so `UpdateInvoiceCustomer` and
`UpdateSupplier` no longer expose a mutable `id`. Update the AIP models around
`InvoiceCustomer` and `Supplier` to keep `id` only under read visibility,
matching the behavior of `mergeInvoiceCustomerFromAPI` and
`mergeInvoiceSupplierFromAPI` that preserve the existing ID regardless of
request input.

In `@api/v3/handlers/billinginvoices/convert.go`:
- Around line 574-577: The error from
AsUpdateBillingWorkflowPaymentSendInvoiceSettings in the billing invoice
conversion flow is being returned as a generic error, which makes malformed
request payloads surface like a server failure. Update the error handling in the
same pattern used elsewhere in this function by wrapping that failure as
billing.ValidationError so client-side bad input still maps to a 400. Keep the
change local to the payment send-invoice settings branch in convert.go and
preserve the existing error context text.
- Around line 614-643: The duplicate invoice line ID issue in the
line-processing loop lets the same existing line be merged and appended more
than once because mergeStandardLineFromAPI mutates a shared billing.StandardLine
and the current checks only validate per-line state. Add a duplicate-ID
rejection step in NewStandardInvoiceLines before this loop (or during iteration)
using the existing linesByID/foundLines flow so repeated non-empty stdLine.Id
values are detected and վերադարձ an error instead of appending duplicates; keep
the change localized around NewStandardInvoiceLines, mergeStandardLineFromAPI,
and StandardInvoiceLines.Validate.

---

Outside diff comments:
In `@api/spec/packages/aip/src/shared/resource.tsp`:
- Around line 77-83: The `ResourceReference<T>.id` field in `ResourceReference`
is currently visible for updates, which causes `UpdateResourceReference`-based
payloads to expand across all consumers. Update the `@visibility` on `id` to
keep it read/create-only, and ensure any generated update shapes for references
like `ProductCatalog.RateCard.feature` and
`ProductCatalog.RateCardTaxConfig.code` do not include `id` as an editable
field.

---

Nitpick comments:
In `@api/spec/packages/aip-client-javascript/src/models/schemas.ts`:
- Around line 1697-1702: The shared updateResourceReference schema is
incorrectly hardcoded with a TaxCode-specific description, which makes this
reusable helper misleading. Update the schema in updateResourceReference so it
uses a generic description or no description at all, and move any
resource-specific wording to the instantiating property/documentation in
resource.tsp or request.tsp where the reference is actually used.
- Around line 4144-4145: The generated public export name is opaque here, so
update the schema generation around the anonymous array model used by array and
array_2 to produce descriptive symbols instead of generic names. Add or
propagate a friendly name for the underlying TypeSpec array properties so the
exported z.array(...) constants in schemas.ts get stable, meaningful
identifiers, and make the same adjustment for both the updatePriceTier and
updateInvoiceLine cases to avoid future collisions.

In `@api/spec/packages/aip/src/shared/request.tsp`:
- Around line 34-42: Add a short docstring note on UpdateRequestNested<T>
clarifying that it is a full-replace update shape for nested sections, not a
partial patch like UpdateRequest<T>. Explain that because it omits
OptionalProperties, callers updating a nested object (for example via workflow)
must supply the complete sub-object including any defaulted fields such as
auto_advance and draft_period. Keep the note alongside the UpdateRequestNested
model and reference its relationship to FilterVisibility/DefaultKeyVisibility so
readers understand the contract.

In `@e2e/billinginvoices_v3_test.go`:
- Around line 887-914: The supplier round-trip helper in
updateSupplierFromInvoiceParty drops TaxId, which can unintentionally clear an
existing tax ID because UpdateSupplier replaces the full object. Update
updateSupplierFromInvoiceParty to copy the invoice party’s TaxId into the
returned apiv3.UpdateSupplier alongside Id and Name, and keep the address
mapping unchanged.
- Around line 719-745: The v3 PUT success-path test in the billing invoice
update case only checks Id and Description, so it won’t catch regressions in
mutable fields handled by mergeStandardInvoiceFromAPI. Extend the assertions in
the “Should update the standard invoice via v3 PUT” test to verify the returned
standard invoice’s Customer, Supplier, and WorkflowSettings match the
request/expected merged state, using the existing updateReq, updatedInv, and
AsBillingInvoiceStandard symbols to locate the assertions.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 62acc145-d17e-4b88-8e8d-3473519fafc5

📥 Commits

Reviewing files that changed from the base of the PR and between afec0f5 and 0199f3c.

⛔ Files ignored due to path filters (1)
  • api/v3/openapi.yaml is excluded by !**/openapi.yaml
📒 Files selected for processing (19)
  • api/spec/packages/aip-client-javascript/README.md
  • api/spec/packages/aip-client-javascript/src/funcs/invoices.ts
  • api/spec/packages/aip-client-javascript/src/index.ts
  • api/spec/packages/aip-client-javascript/src/models/operations/invoices.ts
  • api/spec/packages/aip-client-javascript/src/models/schemas.ts
  • api/spec/packages/aip-client-javascript/src/models/types.ts
  • api/spec/packages/aip-client-javascript/src/sdk/invoices.ts
  • api/spec/packages/aip/src/invoices/invoice.tsp
  • api/spec/packages/aip/src/invoices/operations.tsp
  • api/spec/packages/aip/src/shared/request.tsp
  • api/spec/packages/aip/src/shared/resource.tsp
  • api/v3/api.gen.go
  • api/v3/handlers/billinginvoices/convert.go
  • api/v3/handlers/billinginvoices/convert_test.go
  • api/v3/handlers/billinginvoices/handler.go
  • api/v3/handlers/billinginvoices/update.go
  • api/v3/server/routes.go
  • e2e/billinginvoices_v3_test.go
  • e2e/v3helpers_test.go

Comment on lines +3789 to +3790
export type Array = UpdatePriceTier[]

@coderabbitai coderabbitai Bot Jul 3, 2026

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.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm both duplicate "export type Array" occurrences and check for any others in the file
rg -n '^export type Array' api/spec/packages/aip-client-javascript/src/models/types.ts

Repository: openmeterio/openmeter

Length of output: 255


Rename one of the generated Array aliases upstreamapi/spec/packages/aip-client-javascript/src/models/types.ts has two top-level export type Array declarations (UpdatePriceTier[] and UpdateInvoiceStandardLine[]), so TypeScript will hit TS2300. Since this is generator output, fix the TypeSpec source rather than hand-editing the generated file.

🤖 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 `@api/spec/packages/aip-client-javascript/src/models/types.ts` around lines
3789 - 3790, The generated types file has a duplicate top-level Array alias,
causing a TypeScript TS2300 conflict. Update the upstream TypeSpec/generator
source for the affected model aliases (the Array exports for UpdatePriceTier and
UpdateInvoiceStandardLine) so only one unique name is emitted, and regenerate
the client instead of editing types.ts directly.

Source: Path instructions

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tothandras should I do something with this?

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.

@borosr Yeah, this one's worth a fix — since both Array aliases are auto-generated from anonymous array types in the TypeSpec source, they collide once compiled. Since api/spec/packages/aip-client-javascript/** is generator output, the fix needs to happen upstream in the .tsp files (likely api/spec/packages/aip/src/invoices/invoice.tsp, where the tiers: UpdatePriceTier[] and lines: UpdateInvoiceStandardLine[] properties are defined), not directly in types.ts.

The usual way to dodge this with the generator is to give the array type an explicit named alias in TypeSpec (e.g. model UpdatePriceTierList is Array<UpdatePriceTier>; or similar) instead of leaving it as an inline T[], so the generator emits a unique name instead of falling back to the generic Array label for both. Once that's done, a generate run should produce non-colliding names in types.ts.

Worth flagging to @tothandras for a look since it touches the TypeSpec generator config/pattern rather than just this PR's Go code.

no rush, just don't want a TS2300 surprise sneaking into a build 🐇

Comment on lines +344 to +354
@withVisibility(Lifecycle.Read, Lifecycle.Update)
model InvoiceCustomer {
...PickProperties<
Customers.Customer,
"id" | "name" | "usage_attribution" | "billing_address"
"name" | "usage_attribution" | "billing_address"
>;

#suppress "@openmeter/api-spec-aip/doc-decorator" "shared model"
@visibility(Lifecycle.Read, Lifecycle.Update)
id: Shared.ULID;

@coderabbitai coderabbitai Bot Jul 3, 2026

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Update-visible id fields that the backend silently ignores.

Both InvoiceCustomer.id (required) and Supplier.id (optional) are marked Lifecycle.Update, so they'll show up in UpdateInvoiceCustomer/UpdateSupplier request bodies. But per the Go conversion tests, the backend explicitly discards whatever value is sent here and keeps the existing ID: mergeInvoiceCustomerFromAPI and mergeInvoiceSupplierFromAPI preserve CustomerID/ID regardless of the payload's id.

That's a confusing contract — especially for InvoiceCustomer.id, which is required in the update body, forcing every caller to supply a value that has zero effect. Worth making these Read-only in the update view (drop Lifecycle.Update from id) so the generated types don't imply the field is mutable.

🔧 Proposed fix
 `@friendlyName`("BillingInvoiceCustomer")
 `@withVisibility`(Lifecycle.Read, Lifecycle.Update)
 model InvoiceCustomer {
   ...PickProperties<
     Customers.Customer,
     "name" | "usage_attribution" | "billing_address"
   >;

   `#suppress` "`@openmeter/api-spec-aip/doc-decorator`" "shared model"
-  `@visibility`(Lifecycle.Read, Lifecycle.Update)
+  `@visibility`(Lifecycle.Read)
   id: Shared.ULID;
 `@friendlyName`("BillingSupplier")
 `@withVisibility`(Lifecycle.Read, Lifecycle.Update)
 model Supplier {
   ...OmitProperties<BillingParty, "id" | "key">;

   /**
    * Unique identifier for the party.
    */
-  `@visibility`(Lifecycle.Read, Lifecycle.Update)
+  `@visibility`(Lifecycle.Read)
   id?: string;
 }

Also applies to: 373-382

🤖 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 `@api/spec/packages/aip/src/invoices/invoice.tsp` around lines 344 - 354, Make
the `id` fields read-only in update payloads because the backend ignores them
anyway: in `InvoiceCustomer` and `Supplier`, remove `Lifecycle.Update` from the
`id` visibility so `UpdateInvoiceCustomer` and `UpdateSupplier` no longer expose
a mutable `id`. Update the AIP models around `InvoiceCustomer` and `Supplier` to
keep `id` only under read visibility, matching the behavior of
`mergeInvoiceCustomerFromAPI` and `mergeInvoiceSupplierFromAPI` that preserve
the existing ID regardless of request input.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@tothandras I needed this for the update, should I do it differently?

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.

@borosr Good question! I think the original suggestion still stands as the cleanest fix here.

The key difference is between the lines collection and the singleton customer/supplier snapshots:

  • For InvoiceStandardLine.id, keeping Lifecycle.Update makes sense because the backend actually uses it — to match against existing lines, decide whether to create a new one, or delete lines omitted from the request.
  • For InvoiceCustomer.id and Supplier.id, there's nothing to match against — there's only one customer/supplier snapshot per invoice, and mergeInvoiceCustomerFromAPI/mergeInvoiceSupplierFromAPI always keep the existing ID no matter what's sent. So exposing id as updatable there just invites confusion (and forces callers to send a required-but-ignored field for InvoiceCustomer).

So I'd go with dropping Lifecycle.Update from those two id fields (keep Lifecycle.Read only), while leaving InvoiceStandardLine.id as-is since that one genuinely drives update behavior. That keeps the generated SDK types honest about what's actually mutable.

 `@friendlyName`("BillingInvoiceCustomer")
 `@withVisibility`(Lifecycle.Read, Lifecycle.Update)
 model InvoiceCustomer {
   ...PickProperties<
     Customers.Customer,
     "name" | "usage_attribution" | "billing_address"
   >;

   `#suppress` "`@openmeter/api-spec-aip/doc-decorator`" "shared model"
-  `@visibility`(Lifecycle.Read, Lifecycle.Update)
+  `@visibility`(Lifecycle.Read)
   id: Shared.ULID;
 `@friendlyName`("BillingSupplier")
 `@withVisibility`(Lifecycle.Read, Lifecycle.Update)
 model Supplier {
   ...OmitProperties<BillingParty, "id" | "key">;

   /**
    * Unique identifier for the party.
    */
-  `@visibility`(Lifecycle.Read, Lifecycle.Update)
+  `@visibility`(Lifecycle.Read)
   id?: string;
 }

Let's see what @tothandras thinks too, but this should keep the spec in parity with the actual merge behavior without losing anything you need for the update flow.


🧠 Learnings used
Learnt from: tothandras
Repo: openmeterio/openmeter PR: 3859
File: api/spec/src/v3/currencies/cost-basis/cost_basis.tsp:21-22
Timestamp: 2026-02-19T10:10:51.323Z
Learning: In TypeSpec API specs, prefer using Shared.Numeric for financial values (e.g., exchange rates, amounts, prices) instead of native numeric types like decimal128 or float64. Shared.Numeric is a project-standard string-based scalar with arbitrary precision defined in api/spec/src/v3/shared/properties.tsp. Apply this guideline to relevant cost/spec fields (e.g., in api/spec/src/v3/currencies/cost-basis/cost_basis.tsp) and, more broadly, to all TypeSpec files under api/spec to ensure consistent precision handling and serialization.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked this change, but the result is not identical, so I won't apply these changes.

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.

@borosr Totally fair — thanks for actually testing it out rather than just taking the suggestion at face value. If you ever want to dig into why the output diverges (e.g., something in the OpenAPI snapshot or generated SDK types), feel free to loop me in with more details and I'm happy to take another look. Otherwise, I'll leave it as-is — your call here.

(=^・ω・^=)


🧠 Learnings used
Learnt from: tothandras
Repo: openmeterio/openmeter PR: 3859
File: api/spec/src/v3/currencies/cost-basis/cost_basis.tsp:21-22
Timestamp: 2026-02-19T10:10:51.323Z
Learning: In TypeSpec API specs, prefer using Shared.Numeric for financial values (e.g., exchange rates, amounts, prices) instead of native numeric types like decimal128 or float64. Shared.Numeric is a project-standard string-based scalar with arbitrary precision defined in api/spec/src/v3/shared/properties.tsp. Apply this guideline to relevant cost/spec fields (e.g., in api/spec/src/v3/currencies/cost-basis/cost_basis.tsp) and, more broadly, to all TypeSpec files under api/spec to ensure consistent precision handling and serialization.

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.

Your change is correct.

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.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment thread api/v3/handlers/billinginvoices/convert.go
Comment thread api/v3/handlers/billinginvoices/convert.go

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
pkg/set/set.go (1)

36-43: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use RLock instead of Lock for a read-only check.

Has only reads the map, so grabbing the write lock is heavier than it needs to be — unnecessarily blocks concurrent readers. IsEmpty right below already does this correctly with RLock/RUnlock, so let's stay consistent.

⚡ Proposed fix
 func (s *Set[T]) Has(item T) bool {
-	s.mu.Lock()
-	defer s.mu.Unlock()
+	s.mu.RLock()
+	defer s.mu.RUnlock()

 	_, exists := s.content[item]
 	return exists
 }
🤖 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 `@pkg/set/set.go` around lines 36 - 43, The Has method on Set[T] is performing
a read-only map lookup while taking the exclusive lock, which blocks other
readers unnecessarily. Update Has to use the read lock path instead, matching
the IsEmpty method’s RLock/RUnlock pattern, and keep the existing
mutex-protected access to s.content so concurrent reads remain safe.

Source: Path instructions

🤖 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.

Nitpick comments:
In `@pkg/set/set.go`:
- Around line 36-43: The Has method on Set[T] is performing a read-only map
lookup while taking the exclusive lock, which blocks other readers
unnecessarily. Update Has to use the read lock path instead, matching the
IsEmpty method’s RLock/RUnlock pattern, and keep the existing mutex-protected
access to s.content so concurrent reads remain safe.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 775c00b3-c1a2-4fa8-b637-c8fec16b3b10

📥 Commits

Reviewing files that changed from the base of the PR and between 565037a and a789e9f.

📒 Files selected for processing (2)
  • api/v3/handlers/billinginvoices/convert.go
  • pkg/set/set.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • api/v3/handlers/billinginvoices/convert.go

Comment thread api/v3/handlers/billinginvoices/convert.go
Comment thread api/v3/handlers/billinginvoices/convert.go
turip
turip previously approved these changes Jul 6, 2026
fix: review fixes

fix: use read-lock for set Has method

fix: review fix

fix: handle validation errors of the UpdateStandardInvoice
@borosr borosr force-pushed the feat/v3-invoice-api-update branch from 1bda16c to fef9d52 Compare July 6, 2026 10:23
@borosr borosr enabled auto-merge (squash) July 6, 2026 11:33
@borosr borosr merged commit ef77b70 into main Jul 6, 2026
29 checks passed
@borosr borosr deleted the feat/v3-invoice-api-update branch July 6, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/feature New feature or request release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants