Skip to content

feat: v3 invoice update endpoint#4639

Open
borosr wants to merge 3 commits into
mainfrom
feat/v3-invoice-api-update
Open

feat: v3 invoice update endpoint#4639
borosr wants to merge 3 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}.
    • Enabled draft standard invoice edits (description/labels, supplier/customer snapshots, workflow invoicing/payment settings) and standard line updates (match by ID, create new lines, delete omitted lines).
    • Expanded exported SDK TypeScript types to cover update request/response payloads for invoices, workflow settings, and pricing/tax/rate-card structures.
  • Bug Fixes
    • Improved invoice update conversion/merge behavior for line reconciliation, due-after handling, and identifier semantics.
  • Tests
    • Added end-to-end and conversion tests for successful updates and error cases.

@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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3c5f2bfe-1b29-41dc-8c2e-6709dd8796a3

📥 Commits

Reviewing files that changed from the base of the PR and between a789e9f and 2ce583a.

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

📝 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 JS types, schemas, client, and docs
api/spec/packages/aip-client-javascript/src/models/types.ts, schemas.ts, index.ts, src/models/operations/invoices.ts, src/funcs/invoices.ts, src/sdk/invoices.ts, README.md
Adds update request/response types, generated schemas, SDK method wiring, and README documentation for invoice updates.
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 UpdateBillingInvoice
  participant BillingService

  Client->>Server: PUT /api/v3/openmeter/billing/invoices/{invoiceId}
  Server->>UpdateBillingInvoice: delegate request
  UpdateBillingInvoice->>BillingService: GetInvoiceById
  UpdateBillingInvoice->>BillingService: UpdateStandardInvoice
  BillingService-->>UpdateBillingInvoice: updated invoice
  UpdateBillingInvoice-->>Server: JSON response
Loading

Possibly related PRs

  • openmeterio/openmeter#4541: Adds the v3 billing-invoice GET path and shared invoice conversion/handlers, which this PR extends with the corresponding PUT update flow.
  • openmeterio/openmeter#4544: Also works on invoice update behavior and the backend update path for standard invoices.
  • openmeterio/openmeter#4573: Touches the same v3 billing-invoice conversion layer used by the new update endpoint.

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

Suggested reviewers: rolosp, tothandras, turip

🚥 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 matches the main change: adding a 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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 introduces a PUT /openmeter/billing/invoices/{invoiceId} endpoint for the v3 API, allowing mutation of draft standard invoices without opening up the full v1 surface. Editable fields are scoped to description, labels, supplier/customer snapshots, workflow settings, and top-level lines; identity fields (customer ID, supplier ID, currency, status) are preserved regardless of what the client sends.

  • TypeSpec + generated code: mutable fields gain Lifecycle.Update visibility; id? is extracted out of Resource for lines so it can be optional on update; due_after is intentionally read-only in the invoicing sub-object and only writable via payment.send_invoice.due_after.
  • Handler (update.go): pre-validates invoice type (returns 404 for gathering invoices), dispatches merge via EditFn, and returns the updated invoice.
  • Converter (convert.go): adds mergeStandardInvoiceFromAPI and helpers for supplier/customer/workflow/lines, including duplicate-ID detection and tombstoning of omitted lines.
  • Tests: 4 unit converter tests and a 9-subtask e2e covering happy path, 404 cases, and validation errors.

Confidence Score: 4/5

Safe to merge after fixing the stale DueAfter value that persists when switching from send_invoice to charge_automatically

The merge logic in mergeInvoiceWorkflowFromAPI correctly handles send_invoice and resets DueAfter, but forgets to clear it when the caller switches to charge_automatically. The stale duration then surfaces in the response under invoicing.due_after, returning incorrect state to clients. Everything else — immutable field preservation, tombstoning of omitted lines, duplicate-ID detection, and the 404 guard for gathering invoices — looks correct and is well-tested.

api/v3/handlers/billinginvoices/convert.go — the charge_automatically branch in mergeInvoiceWorkflowFromAPI needs to reset DueAfter

Important Files Changed

Filename Overview
api/v3/handlers/billinginvoices/convert.go Adds merge helpers for supplier/customer/workflow/lines; stale DueAfter not cleared when switching to charge_automatically
api/v3/handlers/billinginvoices/update.go New update handler that pre-validates invoice type, dispatches merge, and returns the updated invoice; looks correct
api/v3/handlers/billinginvoices/convert_test.go Unit tests for tombstone-on-omit, nil lines no-op, immutable field preservation for customer/supplier — good coverage of converter edge cases
api/spec/packages/aip/src/invoices/invoice.tsp Adds Update visibility to mutable invoice fields and splits id? out of Resource; due_after is intentionally read-only in invoicing, writable only via payment.send_invoice
e2e/billinginvoices_v3_test.go End-to-end tests for update happy path, 404 for gathering/unknown IDs, 400 for bad type/negative amount, and line addition; doesn't cover charge_automatically DueAfter clearing
pkg/set/set.go Adds thread-safe Has() method to Set, correctly using RLock; straightforward and safe
api/v3/server/routes.go Adds UpdateInvoice route delegate, wires to new handler method; minimal and correct

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant UpdateInvoiceHandler
    participant BillingService
    participant mergeStandardInvoiceFromAPI

    Client->>UpdateInvoiceHandler: "PUT /billing/invoices/{id} (UpdateInvoiceRequest)"
    UpdateInvoiceHandler->>BillingService: GetInvoiceById(id)
    BillingService-->>UpdateInvoiceHandler: "Invoice (Standard | Gathering)"
    alt Invoice is not Standard
        UpdateInvoiceHandler-->>Client: 404 Not Found
    end
    UpdateInvoiceHandler->>BillingService: UpdateStandardInvoice(EditFn)
    BillingService->>mergeStandardInvoiceFromAPI: "EditFn(inv *StandardInvoice)"
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: merge description/labels
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceSupplierFromAPI (preserve ID)
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceCustomerFromAPI (preserve CustomerID/Key)
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceWorkflowFromAPI
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeStandardInvoiceLinesFromAPI (match by ID, tombstone omitted)
    mergeStandardInvoiceFromAPI-->>BillingService: mutated invoice
    BillingService-->>UpdateInvoiceHandler: updated StandardInvoice
    UpdateInvoiceHandler->>UpdateInvoiceHandler: ToAPIBillingInvoice
    UpdateInvoiceHandler-->>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 UpdateInvoiceHandler
    participant BillingService
    participant mergeStandardInvoiceFromAPI

    Client->>UpdateInvoiceHandler: "PUT /billing/invoices/{id} (UpdateInvoiceRequest)"
    UpdateInvoiceHandler->>BillingService: GetInvoiceById(id)
    BillingService-->>UpdateInvoiceHandler: "Invoice (Standard | Gathering)"
    alt Invoice is not Standard
        UpdateInvoiceHandler-->>Client: 404 Not Found
    end
    UpdateInvoiceHandler->>BillingService: UpdateStandardInvoice(EditFn)
    BillingService->>mergeStandardInvoiceFromAPI: "EditFn(inv *StandardInvoice)"
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: merge description/labels
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceSupplierFromAPI (preserve ID)
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceCustomerFromAPI (preserve CustomerID/Key)
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeInvoiceWorkflowFromAPI
    mergeStandardInvoiceFromAPI->>mergeStandardInvoiceFromAPI: mergeStandardInvoiceLinesFromAPI (match by ID, tombstone omitted)
    mergeStandardInvoiceFromAPI-->>BillingService: mutated invoice
    BillingService-->>UpdateInvoiceHandler: updated StandardInvoice
    UpdateInvoiceHandler->>UpdateInvoiceHandler: ToAPIBillingInvoice
    UpdateInvoiceHandler-->>Client: 200 BillingInvoice
Loading

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
api/v3/handlers/billinginvoices/convert.go:571-572
**Stale `DueAfter` persists after switching to `charge_automatically`**

When the payment method is changed from `send_invoice` to `charge_automatically`, `existing.Config.Invoicing.DueAfter` is never cleared. Because `toAPIWorkflow` always emits `DueAfter` under the invoicing section (`lo.EmptyableToPtr(config.Invoicing.DueAfter.String())`), the GET response after the update will still show `invoicing.due_after` with the old duration even though the payment method is now `charge_automatically`. The fix is to reset `DueAfter` to its zero value in the `charge_automatically` branch.

```suggestion
		case "charge_automatically":
			existing.Config.Payment.CollectionMethod = billing.CollectionMethodChargeAutomatically
			existing.Config.Invoicing.DueAfter = billing.DefaultWorkflowConfig.Invoicing.DueAfter
```

Reviews (3): Last reviewed commit: "fix: use read-lock for set Has method" | 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 on lines +571 to +572
case "charge_automatically":
existing.Config.Payment.CollectionMethod = billing.CollectionMethodChargeAutomatically

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.

P1 Stale DueAfter persists after switching to charge_automatically

When the payment method is changed from send_invoice to charge_automatically, existing.Config.Invoicing.DueAfter is never cleared. Because toAPIWorkflow always emits DueAfter under the invoicing section (lo.EmptyableToPtr(config.Invoicing.DueAfter.String())), the GET response after the update will still show invoicing.due_after with the old duration even though the payment method is now charge_automatically. The fix is to reset DueAfter to its zero value in the charge_automatically branch.

Suggested change
case "charge_automatically":
existing.Config.Payment.CollectionMethod = billing.CollectionMethodChargeAutomatically
case "charge_automatically":
existing.Config.Payment.CollectionMethod = billing.CollectionMethodChargeAutomatically
existing.Config.Invoicing.DueAfter = billing.DefaultWorkflowConfig.Invoicing.DueAfter
Prompt To Fix With AI
This is a comment left during a code review.
Path: api/v3/handlers/billinginvoices/convert.go
Line: 571-572

Comment:
**Stale `DueAfter` persists after switching to `charge_automatically`**

When the payment method is changed from `send_invoice` to `charge_automatically`, `existing.Config.Invoicing.DueAfter` is never cleared. Because `toAPIWorkflow` always emits `DueAfter` under the invoicing section (`lo.EmptyableToPtr(config.Invoicing.DueAfter.String())`), the GET response after the update will still show `invoicing.due_after` with the old duration even though the payment method is now `charge_automatically`. The fix is to reset `DueAfter` to its zero value in the `charge_automatically` branch.

```suggestion
		case "charge_automatically":
			existing.Config.Payment.CollectionMethod = billing.CollectionMethodChargeAutomatically
			existing.Config.Invoicing.DueAfter = billing.DefaultWorkflowConfig.Invoicing.DueAfter
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

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.

2 participants