Skip to content

feat(taxcode): add annotations#4061

Merged
chrisgacsal merged 8 commits intomainfrom
feat/tax-codes-add-annotations
Apr 9, 2026
Merged

feat(taxcode): add annotations#4061
chrisgacsal merged 8 commits intomainfrom
feat/tax-codes-add-annotations

Conversation

@borbelyr-kong
Copy link
Copy Markdown
Contributor

@borbelyr-kong borbelyr-kong commented Apr 2, 2026

Summary by CodeRabbit

  • New Features

    • Tax codes now support annotations to store metadata and ownership.
    • Added a marker to designate tax codes as system-managed.
  • Behavior Changes

    • System-managed tax codes are blocked from updates and deletions, returning a 409 Conflict validation error.
  • Tests

    • Added integration tests verifying annotation behavior and enforcement of system-managed protections.
  • Database

    • Added nullable annotations column and GIN index for annotation queries.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 2, 2026

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

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: 05d7ad6c-f4b6-4d67-adfc-28d752f419b8

📥 Commits

Reviewing files that changed from the base of the PR and between b27b99b and c37f15b.

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

📝 Walkthrough

Walkthrough

Adds annotations support to tax codes across DB, model, adapter, service, and API conversion; blocks updates/deletes for system-managed tax codes; includes tests and test utilities for new behavior. (<=50 words)

Changes

Cohort / File(s) Summary
Database Schema
tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.up.sql, tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.down.sql
Add nullable annotations jsonb column to tax_codes and a GIN index; down-migration removes index and column.
Schema Mixins
openmeter/ent/schema/taxcode.go
Add entutils.AnnotationsMixin{} to TaxCode schema mixins so entities persist annotations.
Domain Model & Constants
openmeter/taxcode/taxcode.go, openmeter/taxcode/annotations.go, openmeter/taxcode/errors.go
Add Annotations field and IsManagedBySystem() method; introduce AnnotationKeyManagedBy, AnnotationValueManagedBySystem, and ErrTaxCodeManagedBySystem (validation issue => 409 Conflict).
Service API / Logic
openmeter/taxcode/service.go, openmeter/taxcode/service/taxcode.go
Add Annotations to Create/Update inputs; service now loads existing record and blocks Update/Delete when IsManagedBySystem() is true, returning the validation error.
Adapter & Mapping
openmeter/taxcode/adapter/mapping.go, openmeter/taxcode/adapter/taxcode.go
Persist and map Annotations on create/update and include them when mapping entity→domain object.
API Conversion
api/v3/handlers/taxcodes/convert.go, api/v3/handlers/taxcodes/convert.gen.go
Replace ConvertMetadataToLabels with ConvertMetadataAnnotationsToLabels(taxcode.TaxCode) so labels include annotations; update Goverter mappings to ignore Annotations in some request→input conversions.
Tests & Test Helpers
openmeter/taxcode/testutils/env.go, openmeter/taxcode/service/taxcode_test.go
Add TestEnv helper and tests covering system-managed vs user-managed tax codes, blocked updates/deletes, and annotation preservation.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant API as "API Handler"
    participant Service
    participant Adapter
    participant DB

    Client->>API: Create/Update/Delete TaxCode (may include annotations)
    API->>Service: Convert request → input (some mappings ignore Annotations)
    Service->>Adapter: Create -> persist Metadata + Annotations
    Adapter->>DB: INSERT/UPDATE tax_codes (annotations jsonb)
    Service->>Adapter: Update/Delete -> fetch existing by NamespacedID
    Adapter->>DB: SELECT tax_code
    DB-->>Adapter: existing tax code (includes annotations)
    Adapter-->>Service: mapped TaxCode (Annotations populated)
    Service->>Service: check IsManagedBySystem()
    alt system-managed
        Service-->>Client: return ErrTaxCodeManagedBySystem (409)
    else not system-managed
        Service->>Adapter: proceed with Update/Delete
        Adapter->>DB: UPDATE/DELETE
        DB-->>Adapter: result
        Adapter-->>Service: success
        Service-->>Client: success response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

area/billing, release-note/feature

Suggested reviewers

  • tothandras
  • turip
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main feature: adding annotations support to the taxcode package.

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

✨ 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/tax-codes-add-annotations

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

❤️ Share

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

@borbelyr-kong borbelyr-kong added the release-note/ignore Ignore this change when generating release notes label Apr 2, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
openmeter/taxcode/adapter/mapping.go (1)

33-33: Consider copying annotations defensively before returning the domain object.

Line 33 assigns the map by reference, which can create accidental shared mutations. Copying it (like metadata handling does) is safer.

♻️ Suggested refactor
 func MapTaxCodeFromEntity(entity *db.TaxCode) (taxcode.TaxCode, error) {
 	if entity == nil {
 		return taxcode.TaxCode{}, errors.New("entity is required")
 	}
 
+	var annotations models.Annotations
+	if entity.Annotations != nil {
+		annotations = make(models.Annotations, len(entity.Annotations))
+		for k, v := range entity.Annotations {
+			annotations[k] = v
+		}
+	}
+
 	return taxcode.TaxCode{
 		NamespacedID: models.NamespacedID{
 			Namespace: entity.Namespace,
 			ID:        entity.ID,
 		},
@@
 		AppMappings: lo.FromPtr(entity.AppMappings),
 		Metadata:    models.NewMetadata(entity.Metadata),
-		Annotations: entity.Annotations,
+		Annotations: annotations,
 	}, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/taxcode/adapter/mapping.go` at line 33, The Annotations map is
being assigned by reference (Annotations: entity.Annotations) which risks shared
mutations; change the mapper (in mapping.go) to defensively copy
entity.Annotations into a new map before assigning to the domain object's
Annotations field (similar to how metadata is handled): create an empty map when
entity.Annotations is non-nil, copy each key/value, and assign that new map
(handle nil case by leaving domain Annotations nil or empty consistently).
openmeter/taxcode/service.go (1)

39-39: Recommend guarding system-reserved annotations at the service boundary.

Since this input now accepts annotations, adding validation for reserved/system-managed keys would help prevent accidental externalization of internal control flags later.

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

In `@openmeter/taxcode/service.go` at line 39, The service now accepts Annotations
(models.Annotations) but doesn't guard system-reserved keys; add validation in
the service boundary (inside the methods in service.go that accept
models.Annotations, e.g., Create/Update handlers or the service struct methods)
to either reject requests containing reserved keys or strip them before
persisting; implement a small reservedKeys set (e.g., "_system", "managed_by",
"internal_*") and check incoming annotations against it, returning a clear
validation error (bad request) or removing those keys before calling lower
layers.
openmeter/taxcode/service/taxcode_test.go (1)

33-54: Assert the persisted state after each operation.

These subtests mostly stop at the returned error/value. A refetch after blocked update/delete, a refetch after successful update, and a not-found check after successful delete would lock down the real contract here: the DB row stayed unchanged when blocked and actually changed when allowed.

As per coding guidelines, **/*_test.go: Make sure the tests are comprehensive and cover the changes. Keep a strong focus on unit tests and in-code integration tests.

Also applies to: 67-90

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

In `@openmeter/taxcode/service/taxcode_test.go` around lines 33 - 54, After
asserting errors for UpdateTaxCode and DeleteTaxCode, refetch the persisted
taxcode via the service (e.g., env.Service.GetTaxCode or the equivalent read
method) and assert the DB state: for the blocked UpdateIsBlocked and
DeleteIsBlocked subtests confirm the taxcode row is unchanged (same Name and
other fields) and still present; for the successful update subtest refetch and
assert the Name changed to "updated name"; for the successful delete subtest
refetch and assert the taxcode is not found (expect a not-found error). Use the
same NamespacedID (models.NamespacedID{Namespace: ns, ID: tc.ID}) when
refetching to verify persistence after calling UpdateTaxCode and DeleteTaxCode.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@api/v3/handlers/taxcodes/convert.go`:
- Around line 23-24: Reject any incoming label keys that start with the reserved
prefix "openmeter.io/" when mapping client Labels into Metadata (the code around
the goverter mapping comment "goverter:map Labels Metadata" and input handlers
such as CreateTaxCodeHandler / UpsertTaxCodeHandler or the converter function
that maps Labels->Metadata); implement a validation check on create/upsert input
that returns a ValidationIssue (per API handler conventions) listing the
offending keys rather than copying them into Metadata, and add the same check
wherever Labels are accepted so responses' openmeter.io/ annotations cannot be
spoofed by client input.

In `@openmeter/taxcode/service/taxcode.go`:
- Around line 47-49: Existing legacy tax_code rows have NULL annotations so
UpdateTaxCode and DeleteTaxCode guards (which call existing.IsManagedBySystem())
incorrectly allow edits/deletes; fix by either adding a migration backfill that
UPDATEs annotation=managed_by=system for rows that were created via
GetOrCreateByAppMapping, or modify IsManagedBySystem (or add a helper used by
UpdateTaxCode/DeleteTaxCode) to treat NULL annotation as system-managed for
records whose creation context matches GetOrCreateByAppMapping; ensure the
change references the existing functions GetOrCreateByAppMapping, UpdateTaxCode,
DeleteTaxCode and the annotations column so legacy rows are protected.

---

Nitpick comments:
In `@openmeter/taxcode/adapter/mapping.go`:
- Line 33: The Annotations map is being assigned by reference (Annotations:
entity.Annotations) which risks shared mutations; change the mapper (in
mapping.go) to defensively copy entity.Annotations into a new map before
assigning to the domain object's Annotations field (similar to how metadata is
handled): create an empty map when entity.Annotations is non-nil, copy each
key/value, and assign that new map (handle nil case by leaving domain
Annotations nil or empty consistently).

In `@openmeter/taxcode/service.go`:
- Line 39: The service now accepts Annotations (models.Annotations) but doesn't
guard system-reserved keys; add validation in the service boundary (inside the
methods in service.go that accept models.Annotations, e.g., Create/Update
handlers or the service struct methods) to either reject requests containing
reserved keys or strip them before persisting; implement a small reservedKeys
set (e.g., "_system", "managed_by", "internal_*") and check incoming annotations
against it, returning a clear validation error (bad request) or removing those
keys before calling lower layers.

In `@openmeter/taxcode/service/taxcode_test.go`:
- Around line 33-54: After asserting errors for UpdateTaxCode and DeleteTaxCode,
refetch the persisted taxcode via the service (e.g., env.Service.GetTaxCode or
the equivalent read method) and assert the DB state: for the blocked
UpdateIsBlocked and DeleteIsBlocked subtests confirm the taxcode row is
unchanged (same Name and other fields) and still present; for the successful
update subtest refetch and assert the Name changed to "updated name"; for the
successful delete subtest refetch and assert the taxcode is not found (expect a
not-found error). Use the same NamespacedID (models.NamespacedID{Namespace: ns,
ID: tc.ID}) when refetching to verify persistence after calling UpdateTaxCode
and DeleteTaxCode.
🪄 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: b48a85f0-8c70-4967-b971-8fefb0b35b51

📥 Commits

Reviewing files that changed from the base of the PR and between a4a2fa1 and f34732b.

⛔ Files ignored due to path filters (10)
  • openmeter/ent/db/entmixinaccessor.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
  • openmeter/ent/db/mutation.go is excluded by !**/ent/db/**
  • openmeter/ent/db/setorclear.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode/taxcode.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode/where.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode_create.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode_update.go is excluded by !**/ent/db/**
  • tools/migrate/migrations/atlas.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (14)
  • api/v3/handlers/taxcodes/convert.gen.go
  • api/v3/handlers/taxcodes/convert.go
  • openmeter/ent/schema/taxcode.go
  • openmeter/taxcode/adapter/mapping.go
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/annotations.go
  • openmeter/taxcode/errors.go
  • openmeter/taxcode/service.go
  • openmeter/taxcode/service/taxcode.go
  • openmeter/taxcode/service/taxcode_test.go
  • openmeter/taxcode/taxcode.go
  • openmeter/taxcode/testutils/env.go
  • tools/migrate/migrations/20260402082013_add_annotations_to_taxcode.down.sql
  • tools/migrate/migrations/20260402082013_add_annotations_to_taxcode.up.sql

Comment on lines 23 to +24
// goverter:map Labels Metadata
// goverter:ignore Annotations
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Reserve the openmeter.io/ label namespace on write.

Create/upsert still copy client Labels straight into Metadata, while responses now emit annotations under the same openmeter.io/ prefix. That lets a caller send openmeter.io/managed_by=system on a user-managed tax code and get back a label that looks identical to a real system annotation. Please reject that prefix with a ValidationIssue, or otherwise reserve it on input, so the annotation namespace stays trustworthy.

As per coding guidelines, api/**/*.go: Follow HTTP handler patterns and ValidationIssue conventions as documented in the /api skill.

Also applies to: 29-32, 86-109

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

In `@api/v3/handlers/taxcodes/convert.go` around lines 23 - 24, Reject any
incoming label keys that start with the reserved prefix "openmeter.io/" when
mapping client Labels into Metadata (the code around the goverter mapping
comment "goverter:map Labels Metadata" and input handlers such as
CreateTaxCodeHandler / UpsertTaxCodeHandler or the converter function that maps
Labels->Metadata); implement a validation check on create/upsert input that
returns a ValidationIssue (per API handler conventions) listing the offending
keys rather than copying them into Metadata, and add the same check wherever
Labels are accepted so responses' openmeter.io/ annotations cannot be spoofed by
client input.

Comment on lines +47 to +49
if existing.IsManagedBySystem() {
return taxcode.TaxCode{}, taxcode.ErrTaxCodeManagedBySystem
}
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 2, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for migrations/backfills or compatibility logic related to legacy taxcode annotations.
rg -n -C3 'AnnotationKeyManagedBy|managed_by|GetOrCreateByAppMapping|SetAnnotations|UPDATE .*tax.?code|ALTER TABLE .*tax.?code|annotations' \
  --glob '*.go' --glob '*.sql' --glob '*.yaml' --glob '*.yml'

Repository: openmeterio/openmeter

Length of output: 50379


🏁 Script executed:

cat tools/migrate/migrations/20260402082013_add_annotations_to_taxcode.up.sql

Repository: openmeterio/openmeter

Length of output: 290


🏁 Script executed:

cat tools/migrate/migrations/20260402082013_add_annotations_to_taxcode.down.sql

Repository: openmeterio/openmeter

Length of output: 255


🏁 Script executed:

head -200 openmeter/taxcode/service/taxcode.go

Repository: openmeterio/openmeter

Length of output: 4647


Legacy tax codes need a backfill to enforce the new system-managed protection.

The migration adds the annotations column but doesn't backfill existing rows with managed_by=system. Since GetOrCreateByAppMapping only stamps that annotation on new creates, any tax codes that were auto-created before this rollout will have a NULL annotation. The new guards in UpdateTaxCode (line 47) and DeleteTaxCode (line 141) check existing.IsManagedBySystem(), which will return false for NULL values, letting legacy rows slip through editable/deletable.

Either add an UPDATE statement to the migration to backfill existing rows created by GetOrCreateByAppMapping, or implement a compatibility check that treats missing annotations as system-managed based on creation context.

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

In `@openmeter/taxcode/service/taxcode.go` around lines 47 - 49, Existing legacy
tax_code rows have NULL annotations so UpdateTaxCode and DeleteTaxCode guards
(which call existing.IsManagedBySystem()) incorrectly allow edits/deletes; fix
by either adding a migration backfill that UPDATEs annotation=managed_by=system
for rows that were created via GetOrCreateByAppMapping, or modify
IsManagedBySystem (or add a helper used by UpdateTaxCode/DeleteTaxCode) to treat
NULL annotation as system-managed for records whose creation context matches
GetOrCreateByAppMapping; ensure the change references the existing functions
GetOrCreateByAppMapping, UpdateTaxCode, DeleteTaxCode and the annotations column
so legacy rows are protected.

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.

GetOrCreateByAppMapping is no longer sets annotations and also we don't want to backfill.

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.

@borbelyr-kong, got it — thanks for the clarification! Since GetOrCreateByAppMapping doesn't stamp the managed_by=system annotation, the IsManagedBySystem() guard is intentionally selective, only protecting records that are explicitly annotated as system-managed through some other path. The legacy rows were never meant to be protected, so no backfill is needed. My concern was off-base here. 🙏

( ˆ◡ˆ)っ✎
scribbling notes carefully


✏️ Learnings added
Learnt from: borbelyr-kong
Repo: openmeterio/openmeter PR: 4061
File: openmeter/taxcode/service/taxcode.go:47-49
Timestamp: 2026-04-08T08:10:19.698Z
Learning: In `openmeter/taxcode/service/taxcode.go`, `GetOrCreateByAppMapping` does NOT set the `managed_by=system` annotation on auto-created tax codes. Only records explicitly annotated elsewhere carry that annotation. Therefore, the `IsManagedBySystem()` guards in `UpdateTaxCode` and `DeleteTaxCode` are intentionally selective — auto-created (legacy) rows without annotations are NOT treated as system-managed, and there is no need for a backfill migration. Do not flag missing backfill or legacy-row compatibility in this file.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: borbelyr-kong
Repo: openmeterio/openmeter PR: 4034
File: openmeter/billing/service/profile.go:85-87
Timestamp: 2026-03-31T11:55:04.046Z
Learning: In `openmeter/billing/service/profile.go`, the `demoteDefaultProfile` function does NOT need an explicit call to `resolveDefaultTaxCode` before writing. When it calls `GetDefaultProfile`, the adapter uses `WithWorkflowConfig(workflowConfigWithTaxCode)` which eager-loads the `TaxCode` edge; `mapWorkflowConfigFromDB` then calls `BackfillTaxConfig` to stamp `DefaultTaxConfig.TaxCodeID` from the DB row. Since `resolveDefaultTaxCode` is idempotent (no-op when `TaxCodeID != nil`), `TaxCodeID` is already populated by the time `adapter.UpdateProfile` is called. Do not flag the absence of `resolveDefaultTaxCode` in `demoteDefaultProfile` as a missing dual-write guard.

Learnt from: turip
Repo: openmeterio/openmeter PR: 4077
File: openmeter/billing/charges/usagebased/charge.go:93-114
Timestamp: 2026-04-03T12:22:50.169Z
Learning: In the openmeterio/openmeter codebase, the `charge_usage_based`, `charge_usage_based_runs`, and `charge_flat_fees` tables had no pre-existing rows when the `feature_id` column (NOT NULL for usage-based) was introduced in migration `20260403090155_charges_feature_ids`. There is no legacy data that lacks `feature_id`, so no backfill strategy is needed and `State.Validate()` requiring a non-empty `FeatureID` is safe.

Learnt from: turip
Repo: openmeterio/openmeter PR: 4023
File: openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go:112-127
Timestamp: 2026-03-28T18:44:25.086Z
Learning: In `openmeter/billing/worker/subscriptionsync/service/reconciler/patchinvoicelinehierarchy.go`, the `AddShrink` and `AddExtend` methods do NOT need to clear `DeletedAt` on the split line group (`updatedGroup.DeletedAt = nil`) when reviving managed child lines. Whole group delete/restore is not a scenario that occurs in this system, so this is intentional and not a missing guard. Do not flag the absence of group-level `DeletedAt` revival alongside managed child reactivation in this file.

Learnt from: turip
Repo: openmeterio/openmeter PR: 4053
File: openmeter/billing/charges/usagebased/service/creditsonly.go:125-147
Timestamp: 2026-04-01T15:37:04.838Z
Learning: In `openmeter/billing/charges/usagebased/service/creditsonly.go`, the `DeleteCharge` method on `CreditsOnlyStateMachine` (including all `CreateRunCreditRealization` and `Adapter.DeleteCharge` calls within it) runs inside a `transaction.New`, so all persistence operations are atomic from an external point of view. Do not flag the sequential CreateRunCreditRealization + DeleteCharge calls as non-atomic — they are wrapped in a single DB transaction.

Learnt from: turip
Repo: openmeterio/openmeter PR: 4031
File: openmeter/billing/charges/flatfee/adapter/charge.go:81-86
Timestamp: 2026-04-01T14:14:56.558Z
Learning: In `openmeter/billing/charges/flatfee/adapter/charge.go`, `DeleteCharge` sets `charge.DeletedAt` and `charge.Status` on the in-memory struct before calling `chargemeta.Update`. This is correct: `chargemeta.Update` (in `openmeter/billing/charges/models/chargemeta/mixin.go`) calls `SetOrClearDeletedAt(convert.TimePtrIn(in.DeletedAt, time.UTC))` and `SetStatus(in.Status)`, so both fields are persisted to the DB via the ent mutation. Do not flag the absence of an explicit `SetDeletedAt` call on the ent UpdateOneID builder in `DeleteCharge` as a bug — `chargemeta.Update` already handles this.

Learnt from: turip
Repo: openmeterio/openmeter PR: 4053
File: openmeter/billing/charges/usagebased/adapter/charge.go:117-121
Timestamp: 2026-04-01T15:31:02.589Z
Learning: In this codebase, database transactions are propagated through `context.Context` using `entutils.TransactingRepo` / `entutils.TransactingRepoWithNoValue` together with `SetDriverOnContext` / `GetDriverFromContext`. When a nested call also uses these helpers and the same `ctx` is threaded through, it should automatically join/reuse the existing transaction (including when an adapter field such as `metaAdapter` is not explicitly wrapped with the tx). During code review, do not flag a call as “outside the transaction” solely because a specific adapter instance isn’t wrapped, as long as the transaction driver is obtainable from the provided `ctx` via these helpers.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
api/v3/handlers/taxcodes/convert.go (1)

105-108: Silent drop of non-string annotation values is worth documenting.

The type assertion v.(string) silently skips any annotation values that aren't strings. Currently this is fine since all annotations use string values (per openmeter/taxcode/annotations.go), but a brief comment here would help future maintainers understand this is intentional rather than an oversight.

📝 Suggested documentation
 func ConvertTaxCodeToLabels(source taxcode.TaxCode) *api.Labels {
 	labels := ConvertMetadataToLabels(source.Metadata)
 	for k, v := range source.Annotations {
+		// Only string-valued annotations are surfaced as labels;
+		// non-string values are intentionally skipped.
 		if s, ok := v.(string); ok {
 			(*labels)[annotationLabelPrefix+k] = s
 		}
 	}
 	return labels
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@api/v3/handlers/taxcodes/convert.go` around lines 105 - 108, In convert.go
where the loop over source.Annotations performs a type assertion `v.(string)`
and assigns into `(*labels)[annotationLabelPrefix+k]`, add a concise inline
comment noting that non-string annotation values are intentionally ignored
because all annotations are defined as strings (see
openmeter/taxcode/annotations.go), so the silent skip via the `ok` check is
deliberate and not a bug.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/taxcode/service/taxcode.go`:
- Around line 42-49: The service checks IsManagedBySystem() before calling the
adapter, but the adapter mutations (UpdateTaxCode and DeleteTaxCode) must
enforce the same invariant to avoid race conditions; modify the adapter
implementations for UpdateTaxCode and DeleteTaxCode to include a predicate that
excludes rows with annotations->>'managed_by' = 'system' (i.e., add a WHERE
clause that annotations->>'managed_by' != 'system' or NULL) so the DB will
refuse updates/deletes on system-managed tax codes, and return a not-allowed
error when zero rows are affected; alternatively you can lock the row in
GetTaxCode with FOR UPDATE (used by the service's pre-check) if you prefer
row-locking instead of the WHERE predicate.

In `@openmeter/taxcode/testutils/env.go`:
- Around line 56-79: Create and register a t.Cleanup early in NewTestEnv so
resources are always torn down on test failure: after creating logger, db and
client (from testutils.InitPostgresDB and db.EntDriver.Client) construct a
partial TestEnv value (setting Logger, Client, db and close sync.Once) and call
t.Cleanup(func(){ env.Close() }) before calling taxcodeadapter.New and
require.NoErrorf; then finish initializing adapter and Service and return the
same env. Ensure TestEnv.Close is idempotent (uses the existing sync.Once) so
the cleanup is safe to call even if setup later fails.

---

Nitpick comments:
In `@api/v3/handlers/taxcodes/convert.go`:
- Around line 105-108: In convert.go where the loop over source.Annotations
performs a type assertion `v.(string)` and assigns into
`(*labels)[annotationLabelPrefix+k]`, add a concise inline comment noting that
non-string annotation values are intentionally ignored because all annotations
are defined as strings (see openmeter/taxcode/annotations.go), so the silent
skip via the `ok` check is deliberate and not a bug.
🪄 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: 624c9b71-fbcd-4d81-9f29-53ecc12aefb1

📥 Commits

Reviewing files that changed from the base of the PR and between f34732b and 065d44d.

📒 Files selected for processing (6)
  • api/v3/handlers/taxcodes/convert.go
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/service.go
  • openmeter/taxcode/service/taxcode.go
  • openmeter/taxcode/service/taxcode_test.go
  • openmeter/taxcode/testutils/env.go
✅ Files skipped from review due to trivial changes (1)
  • openmeter/taxcode/service/taxcode_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/service.go

Comment on lines +42 to +49
existing, err := s.adapter.GetTaxCode(ctx, taxcode.GetTaxCodeInput{NamespacedID: input.NamespacedID})
if err != nil {
return taxcode.TaxCode{}, err
}

if existing.IsManagedBySystem() {
return taxcode.TaxCode{}, taxcode.ErrTaxCodeManagedBySystem
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== transaction helpers =="
fd '.go$' pkg/framework/transaction --exec sed -n '1,220p' {}

echo
echo "== taxcode adapter write paths =="
rg -n -C8 '^func .*UpdateTaxCode\(|^func .*DeleteTaxCode\(' openmeter/taxcode/adapter/taxcode.go

echo
echo "== managed_by predicates / row locks =="
rg -n -C4 'FOR UPDATE|managed_by|AnnotationKeyManagedBy|IsManagedBySystem' openmeter/taxcode/adapter/taxcode.go pkg/framework/transaction

Repository: openmeterio/openmeter

Length of output: 4821


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== UpdateTaxCode full implementation ==="
rg -n -A 20 '^func \(a \*adapter\) UpdateTaxCode\(' openmeter/taxcode/adapter/taxcode.go

echo
echo "=== DeleteTaxCode full implementation ==="
rg -n -A 20 '^func \(a \*adapter\) DeleteTaxCode\(' openmeter/taxcode/adapter/taxcode.go

echo
echo "=== TransactingRepo implementation ==="
fd 'entutils' --type f -name '*.go' -path '*openmeter*' | head -5 | xargs rg -l 'TransactingRepo' | head -1 | xargs cat | head -100

echo
echo "=== Isolation level configuration ==="
rg -r 'isolation|ReadCommitted|RepeatableRead|Serializable' pkg/framework --type go | head -20

Repository: openmeterio/openmeter

Length of output: 5155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== TaxCode schema / migrations ==="
fd 'taxcode' pkg/framework/ent migrations -name '*.go' -o -name '*.sql' | head -10 | xargs -I {} sh -c 'echo "FILE: {}"; head -50 {}'

echo
echo "=== entutils.TransactingRepo implementation ==="
fd 'entutils' openmeter pkg --type f -name '*.go' | xargs grep -l 'TransactingRepo' | head -1 | xargs sed -n '1,250p'

echo
echo "=== Isolation level / tx config ==="
rg 'isolation|ReadCommitted|RepeatableRead|Serializable|SetIsolation' pkg/framework --type go --type rust -C 3 | head -50

echo
echo "=== managed_by column definition ==="
rg 'managed_by|ManagedBy|managed.by' openmeter/taxcode -C 2 | head -40

Repository: openmeterio/openmeter

Length of output: 348


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Find ent schema files ==="
find . -path ./node_modules -prune -o -type f -name '*.go' -print | xargs grep -l 'managed_by\|ManagedBy' 2>/dev/null | head -10

echo
echo "=== entutils TransactingRepo implementation ==="
find . -path ./node_modules -prune -o -type f -path '*/entutils*' -name '*.go' -print | xargs cat 2>/dev/null | head -200

echo
echo "=== PostgreSQL isolation config ==="
find . -path ./node_modules -prune -o -type f -name '*.go' -print | xargs grep -i 'isolation\|read.*commit\|repeatable.*read\|serializable' 2>/dev/null | grep -v '.pb.go' | head -30

echo
echo "=== TaxCode ent schema (look for managed_by) ==="
find . -path ./node_modules -prune -o -type f -name 'taxcode.go' -path '*/ent/db/schema/*' -print | xargs cat 2>/dev/null

Repository: openmeterio/openmeter

Length of output: 5627


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== TransactingRepo definition ==="
find . -type f -name '*.go' | xargs grep -l 'func TransactingRepo' | head -5

echo
echo "=== TaxCode schema/entity ==="
find . -type f -name 'taxcode.go' | grep -E 'ent|schema' | head -5 | xargs cat 2>/dev/null

echo
echo "=== managed_by field in TaxCode ==="
find . -type f -name '*.go' | xargs grep -A 5 -B 5 'Field.*managed.*\|managed.*Field' 2>/dev/null | grep -i taxcode -A 10 -B 5 | head -40

echo
echo "=== Database driver and isolation setup ==="
find . -type f -name '*.go' | xargs grep -l 'pgx\|postgres\|isolation' | xargs grep -A 3 'isolation\|ReadCommitted\|Serializable' 2>/dev/null | head -40

Repository: openmeterio/openmeter

Length of output: 34471


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== IsManagedBySystem implementation ==="
find . -type f -name '*.go' | xargs grep -n 'func.*IsManagedBySystem\|ManagedBySystem' | head -20

echo
echo "=== taxcode.TaxCode domain struct ==="
find . -type f -path '*/taxcode/*.go' -name '*.go' ! -path '*/ent/*' ! -path '*/db/*' | xargs grep -l 'type TaxCode struct' | head -1 | xargs cat | head -100

echo
echo "=== Search for managed annotation/key ==="
find . -type f -path '*/taxcode/*.go' | xargs grep -i 'managed\|system' | grep -v '//.*managed' | head -30

Repository: openmeterio/openmeter

Length of output: 7186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== entutils/transaction.go full implementation ==="
cat pkg/framework/entutils/transaction.go

echo
echo "=== Check for SERIALIZABLE or FOR UPDATE in entutils ==="
grep -r 'SERIALIZABLE\|FOR UPDATE\|SetIsolation\|isolation' pkg/framework/entutils/ --include='*.go'

echo
echo "=== Database initialization and isolation config ==="
find . -type f -name '*.go' -path '*/db/*' ! -path '*/ent/*' | xargs grep -l 'isolation\|pgx' | head -5 | xargs grep -A 5 -B 5 'isolation\|Serializable' | head -50

Repository: openmeterio/openmeter

Length of output: 8712


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== TaxCode adapter.Tx() implementation ==="
grep -n 'func.*Tx\|Tx(ctx' openmeter/taxcode/adapter/taxcode.go | head -20

echo
echo "=== Database Client creation / transaction setup ==="
find . -type f -name '*.go' -path '*/db/*' ! -path '*/ent/db/*' ! -path '*/framework/*' | xargs grep -l 'Tx.*sql.TxOptions\|isolation\|ReadCommitted' | head -5

echo
echo "=== Check if any code explicitly sets isolation ==="
grep -r 'TxOptions\|Isolation' openmeter --include='*.go' | grep -v test | head -20

echo
echo "=== Look for TxHijacker implementation in adapter ==="
grep -r 'HijackTx\|TxOptions' openmeter/taxcode --include='*.go'

Repository: openmeterio/openmeter

Length of output: 108


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Full TaxCode adapter definition ==="
head -50 openmeter/taxcode/adapter/taxcode.go

echo
echo "=== Search for Tx method in any adapter ==="
find openmeter -type f -name '*.go' -path '*/adapter/*' | xargs grep -l 'func.*Tx(' | head -3 | xargs grep -A 10 'func.*Tx('

echo
echo "=== Check TxCreator implementation ==="
grep -r 'func.*Tx.*context.Context' openmeter --include='*.go' | grep -v test | grep -v '\.pb\.' | head -10

echo
echo "=== Transaction setup in main/db init ==="
find . -type f -name 'main.go' -o -name 'db.go' -o -name 'setup.go' | xargs grep -l postgres 2>/dev/null | head -3 | xargs grep -A 20 'sql.Open\|pgx\|postgres'

Repository: openmeterio/openmeter

Length of output: 7585


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Check sql.TxOptions and default isolation ==="
grep -r 'sql.TxOptions' openmeter --include='*.go' -B 2 -A 5 | head -60

echo
echo "=== Look for any row locking (FOR UPDATE) in taxcode adapter ==="
grep -r 'FOR UPDATE\|for update\|ForUpdate' openmeter/taxcode --include='*.go'

echo
echo "=== Check if service reads with lock before adapter call ==="
sed -n '40,95p' openmeter/taxcode/service/taxcode.go

echo
echo "=== Check if adapter UpdateTaxCode has WHERE managed_by predicate ==="
sed -n '50,75p' openmeter/taxcode/adapter/taxcode.go

Repository: openmeterio/openmeter

Length of output: 4498


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Exact UpdateTaxCode mutation in adapter ==="
sed -n '50,80p' openmeter/taxcode/adapter/taxcode.go

echo
echo "=== Exact DeleteTaxCode mutation in adapter ==="
sed -n '163,200p' openmeter/taxcode/adapter/taxcode.go

echo
echo "=== Confirm: sql.TxOptions default isolation is READ COMMITTED ==="
echo "PostgreSQL default isolation level is READ COMMITTED when not specified in sql.TxOptions"
echo "The code uses sql.TxOptions{ReadOnly: false} which does NOT set Isolation"
echo "This means READ COMMITTED isolation is in effect"

Repository: openmeterio/openmeter

Length of output: 2263


Add managed_by predicate to adapter writes to prevent race conditions.

The service-layer checks at lines 47–48 and 138–140 (verifying IsManagedBySystem()) run only before calling the adapter, but the adapter itself doesn't re-enforce this invariant. Since annotations[managed_by] is stored in a JSONB column (not a separate DB column), a concurrent transaction can flip the status between the service check and the adapter mutation. With PostgreSQL's default READ COMMITTED isolation and no row locking, another request can successfully modify or delete a tax code after it's been marked as system-managed.

To fix:

  • Add a WHERE clause in UpdateTaxCode (line 56) and DeleteTaxCode (line 182) that excludes rows where annotations->>'managed_by' = 'system', or
  • Use FOR UPDATE when reading to lock the row before the check, or
  • Bump isolation to SERIALIZABLE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/taxcode/service/taxcode.go` around lines 42 - 49, The service
checks IsManagedBySystem() before calling the adapter, but the adapter mutations
(UpdateTaxCode and DeleteTaxCode) must enforce the same invariant to avoid race
conditions; modify the adapter implementations for UpdateTaxCode and
DeleteTaxCode to include a predicate that excludes rows with
annotations->>'managed_by' = 'system' (i.e., add a WHERE clause that
annotations->>'managed_by' != 'system' or NULL) so the DB will refuse
updates/deletes on system-managed tax codes, and return a not-allowed error when
zero rows are affected; alternatively you can lock the row in GetTaxCode with
FOR UPDATE (used by the service's pre-check) if you prefer row-locking instead
of the WHERE predicate.

Comment thread openmeter/taxcode/testutils/env.go
@borbelyr-kong borbelyr-kong marked this pull request as ready for review April 8, 2026 13:00
@borbelyr-kong borbelyr-kong requested a review from a team as a code owner April 8, 2026 13:00
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
openmeter/taxcode/testutils/env.go (1)

25-32: Minor inconsistency: consider reusing e.Client.

Line 30 creates a fresh client via e.db.EntDriver.Client(), but e.Client already holds the same client reference. Using e.Client.Schema.Create(...) would be more consistent and make it clearer you're working with the same client throughout.

💡 Suggested tweak
 func (e *TestEnv) DBSchemaMigrate(t *testing.T) {
 	t.Helper()

 	require.NotNilf(t, e.db, "database must be initialized")

-	err := e.db.EntDriver.Client().Schema.Create(t.Context())
+	err := e.Client.Schema.Create(t.Context())
 	require.NoErrorf(t, err, "schema migration must not fail")
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/taxcode/testutils/env.go` around lines 25 - 32, In DBSchemaMigrate
on TestEnv, switch from creating a fresh client via e.db.EntDriver.Client() to
reusing the existing e.Client (use e.Client.Schema.Create(...)) to keep client
usage consistent; also ensure you assert e.Client is not nil (similar to the
existing require.NotNilf for e.db) before calling Schema.Create to avoid nil
dereference. This touches the DBSchemaMigrate method and the TestEnv fields
e.Client and e.db.EntDriver.Client().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@openmeter/taxcode/testutils/env.go`:
- Around line 34-54: In TestEnv.Close (the e.close.Do block) change the resource
shutdown order to close e.Client first, then e.db.EntDriver, then e.db.PGDriver:
move the e.Client nil-check and Client.Close() call before the driver closes,
preserving the existing error handling (t.Errorf) and the use of e.close.Do to
ensure idempotence; ensure you reference the same symbols (e.Client,
e.db.EntDriver.Close, e.db.PGDriver.Close) and keep behavior otherwise
identical.

---

Nitpick comments:
In `@openmeter/taxcode/testutils/env.go`:
- Around line 25-32: In DBSchemaMigrate on TestEnv, switch from creating a fresh
client via e.db.EntDriver.Client() to reusing the existing e.Client (use
e.Client.Schema.Create(...)) to keep client usage consistent; also ensure you
assert e.Client is not nil (similar to the existing require.NotNilf for e.db)
before calling Schema.Create to avoid nil dereference. This touches the
DBSchemaMigrate method and the TestEnv fields e.Client and
e.db.EntDriver.Client().
🪄 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: 28345bcf-6fc8-44ec-8a3e-e081e68b6eb8

📥 Commits

Reviewing files that changed from the base of the PR and between 065d44d and b22a75a.

⛔ Files ignored due to path filters (10)
  • openmeter/ent/db/entmixinaccessor.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
  • openmeter/ent/db/mutation.go is excluded by !**/ent/db/**
  • openmeter/ent/db/setorclear.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode/taxcode.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode/where.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode_create.go is excluded by !**/ent/db/**
  • openmeter/ent/db/taxcode_update.go is excluded by !**/ent/db/**
  • tools/migrate/migrations/atlas.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (14)
  • api/v3/handlers/taxcodes/convert.gen.go
  • api/v3/handlers/taxcodes/convert.go
  • openmeter/ent/schema/taxcode.go
  • openmeter/taxcode/adapter/mapping.go
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/annotations.go
  • openmeter/taxcode/errors.go
  • openmeter/taxcode/service.go
  • openmeter/taxcode/service/taxcode.go
  • openmeter/taxcode/service/taxcode_test.go
  • openmeter/taxcode/taxcode.go
  • openmeter/taxcode/testutils/env.go
  • tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.down.sql
  • tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.up.sql
✅ Files skipped from review due to trivial changes (6)
  • tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.down.sql
  • tools/migrate/migrations/20260408082246_add_annotations_to_taxcode.up.sql
  • openmeter/taxcode/errors.go
  • openmeter/ent/schema/taxcode.go
  • openmeter/taxcode/annotations.go
  • openmeter/taxcode/service/taxcode_test.go
🚧 Files skipped from review as they are similar to previous changes (6)
  • openmeter/taxcode/adapter/mapping.go
  • api/v3/handlers/taxcodes/convert.gen.go
  • openmeter/taxcode/service.go
  • openmeter/taxcode/adapter/taxcode.go
  • openmeter/taxcode/taxcode.go
  • openmeter/taxcode/service/taxcode.go

Comment thread openmeter/taxcode/testutils/env.go
chrisgacsal
chrisgacsal previously approved these changes Apr 9, 2026
@chrisgacsal chrisgacsal merged commit 3a72439 into main Apr 9, 2026
24 checks passed
@chrisgacsal chrisgacsal deleted the feat/tax-codes-add-annotations branch April 9, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release-note/ignore Ignore this change when generating release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants