Skip to content

fix: creditpurchase shape#4129

Merged
turip merged 3 commits intomainfrom
refactor/creditpurchase-shape
Apr 11, 2026
Merged

fix: creditpurchase shape#4129
turip merged 3 commits intomainfrom
refactor/creditpurchase-shape

Conversation

@turip
Copy link
Copy Markdown
Member

@turip turip commented Apr 10, 2026

Overview

Make sure that the external API surface of creditpurchase matches the usagebased and flatfee ones.

Major changes

Refactored the creditpurchase charge package to follow the same structural pattern as flatfee and usagebased.
All three charge types now share a consistent shape: ChargeBase (base-row data: identity, intent, status,
scheduling state) embedded in Charge alongside Realizations (expand-only edge data). Each type defines its own
Status type with a ToMetaChargeStatus() bridge, persisted in a status_detailed DB column.

Moved CreditGrantRealization out of the creditpurchase base row into its own
charge_credit_purchase_credit_grants edge table. This makes creditpurchase.State empty — all lifecycle outcomes
(CreditGrantRealization, ExternalPaymentSettlement, InvoiceSettlement) now live uniformly in Realizations,
loaded via edge queries when ExpandRealizations is requested.

Restructured the creditpurchase adapter into composite sub-interfaces: ChargeAdapter, CreditGrantAdapter,
ExternalPaymentAdapter, and InvoicedPaymentAdapter. UpdateCharge now accepts ChargeBase instead of the full
Charge, ensuring realization edges are only written through their dedicated adapter methods
(CreateCreditGrant, CreateExternalPayment, etc.). Service methods that only change edge data update
charge.Realizations in memory without calling UpdateCharge.

Fixed all callers across the codebase — API handlers, ledger charge adapters, service code, and tests — to use
type-specific Status comparisons (flatfee.StatusFinal, creditpurchase.StatusActive) instead of
meta.ChargeStatus, and to access realization data through .Realizations instead of .State.

Notes for reviewer

Summary by CodeRabbit

  • Documentation

    • Clarified credit-purchase lifecycle, status mappings, status-bridging guidance, and handler/adapter maintenance instructions.
  • Refactor

    • Split credit-purchase into shared base vs. realizations; introduced dedicated status type and role-specific adapter responsibilities with clearer update semantics.
  • Database

    • Added persistent credit-grant records and a detailed status column with migrations to backfill existing rows.
  • Tests

    • Updated tests to assert against the new realizations structure and new status values.

@turip turip requested a review from a team as a code owner April 10, 2026 15:24
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 10, 2026

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: 0e74784b-2bb9-4d67-ab70-aaec9d9b5c2f

📥 Commits

Reviewing files that changed from the base of the PR and between 0b2f64c and 9ac94d8.

📒 Files selected for processing (1)
  • openmeter/billing/charges/creditpurchase/service/invoice.go

📝 Walkthrough

Walkthrough

Refactors credit-purchase: splits Charge into ChargeBase + Realizations, introduces creditpurchase.Status and status_detailed persistence, extracts credit-grant into its own DB table/edge, composes the Adapter into role-specific interfaces (including CreateCreditGrant), and updates services/tests to use Realizations and the new APIs.

Changes

Cohort / File(s) Summary
Domain model & state
openmeter/billing/charges/creditpurchase/charge.go, openmeter/billing/charges/creditpurchase/statemachine.go
Add ChargeBase and Realizations; introduce creditpurchase.Status with validation and meta conversion; move base validation/error helpers.
Adapter API
openmeter/billing/charges/creditpurchase/adapter.go
Replace monolithic Adapter.UpdateCharge with composed interfaces (ChargeAdapter, CreditGrantAdapter, ExternalPaymentAdapter, InvoicedPaymentAdapter); add GetByIDInput and CreateCreditGrantInput.
Adapter impl & mapping
openmeter/billing/charges/creditpurchase/adapter/charge.go, .../creditgrant.go, .../mapper.go
Update UpdateCharge to accept/return ChargeBase; add GetByID; implement CreateCreditGrant; add MapChargeBaseFromDB and shared expand helper; read grant via new edge and return realizations.
Service handlers
openmeter/billing/charges/creditpurchase/service/...
Handlers now use Realizations.*, call CreateCreditGrant to persist grants, use creditpurchase.Status*, and call UpdateCharge with ChargeBase (merge returned base back).
DB schema & migrations
openmeter/ent/schema/chargescreditpurchase.go, tools/migrate/migrations/..._up.sql, ..._down.sql
Remove denormalized grant columns, add status_detailed enum, create charge_credit_purchase_credit_grants table/edge and indexes; include up/down SQL.
API conversions & tests
api/v3/handlers/customers/credits/convert.go, openmeter/billing/charges/service/creditpurchase_test.go, openmeter/ledger/chargeadapter/creditpurchase_test.go, test/credits/sanity*.go
Convert to/from creditpurchase.Status*; update tests and helpers to assert/read Realizations.* instead of State.*.
Minor behavior & validation
openmeter/billing/charges/flatfee/adapter.go, openmeter/billing/creditgrant/service/service.go
Unconditional validation of IntentWithInitialStatus.InitialStatus; creditgrant.Get now requests expands for realizations.
Docs
.agents/skills/charges/SKILL.md
Documentation updated to describe credit-purchase parity with other charge types, status/state mapping, handler and adapter responsibilities.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Service as CreditPurchaseService
    participant Adapter as Adapter (Charge / CreditGrant / Payment)
    participant DB as Database
    participant Ledger as Ledger

    Client->>Service: Initiate external credit purchase
    Service->>Adapter: CreateCreditGrant(chargeID, TransactionGroupID, GrantedAt)
    Adapter->>DB: Insert charge_credit_purchase_credit_grants row
    DB-->>Adapter: saved grant (TransactionGroupID, GrantedAt)
    Adapter-->>Service: TimedGroupReference
    Service->>Adapter: UpdateCharge(ChargeBase) 
    Adapter->>DB: Update charge_credit_purchases.status_detailed / base fields
    alt external payment auth/settle
      Service->>Adapter: CreateExternalPayment(...) / CreateInvoicedPayment(...)
      Adapter->>Ledger: Record external payment
      Adapter->>DB: Persist payment realization (external/invoiced)
      Adapter-->>Service: Payment realization reference
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • GAlexIHU
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'fix: creditpurchase shape' is vague and doesn't clearly convey the substantial architectural refactoring involved in restructuring the creditpurchase charge package. Consider a more descriptive title that captures the core change, such as 'refactor: restructure creditpurchase charge with composed ChargeBase/Realizations pattern' to better reflect the significant API shape changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 refactor/creditpurchase-shape

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.

@turip turip added release-note/misc Miscellaneous changes area/billing labels Apr 10, 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: 6

🧹 Nitpick comments (3)
test/credits/sanity_lifecycle_test.go (1)

531-543: Guard the new realization pointer before reading .Status.

If the transition stops returning ExternalPaymentSettlement, this shared helper panics instead of failing with a clean assertion. A s.Require().NotNil(updatedCharge.Realizations.ExternalPaymentSettlement) before each status check would make these lifecycle failures much easier to read.

💡 Small test-helper hardening
  s.NoError(err)
+ s.Require().NotNil(updatedCharge.Realizations.ExternalPaymentSettlement)
  s.Equal(payment.StatusAuthorized, updatedCharge.Realizations.ExternalPaymentSettlement.Status)

  updatedCharge, err = s.Charges.HandleCreditPurchaseExternalPaymentStateTransition(ctx, charges.HandleCreditPurchaseExternalPaymentStateTransitionInput{
    ChargeID:           chargeID,
    TargetPaymentState: payment.StatusSettled,
  })
  s.NoError(err)
+ s.Require().NotNil(updatedCharge.Realizations.ExternalPaymentSettlement)
  s.Equal(payment.StatusSettled, updatedCharge.Realizations.ExternalPaymentSettlement.Status)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/credits/sanity_lifecycle_test.go` around lines 531 - 543, The test reads
updatedCharge.Realizations.ExternalPaymentSettlement.Status without guarding the
pointer, which can panic; before each s.Equal that inspects .Status (after calls
to Charges.HandleCreditPurchaseExternalPaymentStateTransition), add
s.Require().NotNil(updatedCharge.Realizations.ExternalPaymentSettlement) to
assert the realization exists and fail the test cleanly if it does not.
openmeter/ent/schema/chargescreditpurchase.go (1)

117-123: Make credit-grant rows append-only in the schema.

transaction_group_id and granted_at look like write-once realization data. Marking them Immutable() would let Ent enforce that invariant instead of relying on callers not to use the generated update builders.

Suggested schema hardening
 		field.String("transaction_group_id").
 			SchemaType(map[string]string{
 				dialect.Postgres: "char(26)",
 			}).
-			NotEmpty(),
+			NotEmpty().
+			Immutable(),

-		field.Time("granted_at"),
+		field.Time("granted_at").
+			Immutable(),
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/ent/schema/chargescreditpurchase.go` around lines 117 - 123, The
schema currently allows updates to write-once fields: add Ent's Immutable() to
the field definitions for "transaction_group_id" (the
field.String("transaction_group_id") chain) and "granted_at" (the
field.Time("granted_at") chain) so Ent will enforce append-only semantics; keep
existing SchemaType and NotEmpty() on transaction_group_id and preserve any
other chained modifiers when adding Immutable() to both field builders in
chargescreditpurchase.go.
openmeter/billing/charges/creditpurchase/charge.go (1)

69-70: Double-check the expand contract for realizations.

Realizations is a value field, so a direct JSON marshal of Charge will still emit "realizations": {} even when nothing was expanded. For an expand-only block, that blurs “not loaded” vs “loaded but empty” and can quietly change the external API shape. If this type is returned directly, I'd make the field nullable or map it through a response DTO that can omit unloaded realizations.

Also applies to: 146-147

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

In `@openmeter/billing/charges/creditpurchase/charge.go` around lines 69 - 70, The
Charge struct's Realizations field is a value type (Realizations) so JSON
marshaling emits "realizations": {} even when not expanded; change the field to
be a pointer (*Realizations) or make Charge-to-response DTO mapping that uses a
nullable/omitted field to distinguish "not loaded" vs "loaded empty". Update the
Charge struct declaration (Realizations) and any code that constructs or
serializes Charge (also check the similar occurrence around the other instance
noted at lines 146-147) to either initialize the pointer only when expanded or
map to a response DTO that omits the realizations key when nil/unset.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.agents/skills/charges/SKILL.md:
- Around line 504-507: The documentation still refers to a non-existent
creditpurchase.State field; update the checklist to reflect the actual Go model:
creditpurchase.Charge is composed of creditpurchase.ChargeBase plus
creditpurchase.Realizations (which contains CreditGrantRealization,
ExternalPaymentSettlement, and InvoiceSettlement) and remove any mention of
State. Specifically, replace the line describing `creditpurchase.State` being
empty with a statement that lifecycle outcomes live in `Realizations`, and
ensure the earlier bullets reference `creditpurchase.ChargeBase`,
`creditpurchase.Charge`, and `creditpurchase.Realizations` exactly as named in
the code.

In `@api/v3/handlers/customers/credits/convert.go`:
- Around line 103-104: convert.go reads charge.Realizations.InvoiceSettlement
and ExternalPaymentSettlement which are expansion-only and may be nil because
the service's Get and Create paths don't request expansions; update the service
methods so charges returned to convert.go always include realizations by passing
Expands: meta.Expands{meta.ExpandRealizations} when fetching
charges—specifically, modify the Get method (which calls GetByID) to call
GetByID with Expands including meta.ExpandRealizations, and update the Create
path to either fetch the newly created charge via GetByID with the same Expands
or ensure Create returns the charge with realizations populated; reference the
Get, Create, and GetByID methods and use meta.Expands/meta.ExpandRealizations to
implement the fix.

In `@openmeter/billing/charges/creditpurchase/adapter/mapper.go`:
- Around line 18-31: MapChargeBaseFromDB currently maps dbEntity.EffectiveAt
only into Intent.EffectiveAt and thus drops the top-level
creditpurchase.ChargeBase.EffectiveAt; update the returned
creditpurchase.ChargeBase literal in MapChargeBaseFromDB to set EffectiveAt:
convert.SafeToUTC(dbEntity.EffectiveAt) (in addition to leaving
Intent.EffectiveAt as-is) so the top-level EffectiveAt is preserved across
create/get/list/update round-trips.

In `@openmeter/billing/charges/creditpurchase/service/promotional.go`:
- Around line 27-30: Replace the fresh clock.Now() used as GrantedAt when
creating the credit grant with the timestamp supplied by the handler’s ledger
transaction group reference: pass ledgerTransactionGroupReference.Timestamp (or
ledgerTransactionGroupReference.GrantedAt if that’s the exact field name) into
creditpurchase.CreateCreditGrantInput. Update the call in CreateCreditGrant (the
grantRealization creation) to use that field instead of clock.Now(), and remove
the now-unused pkg/clock import from the file; this preserves the original
ledger timestamp provided by OnPromotionalCreditPurchase.

In `@tools/migrate/migrations/20260410144542_credit_purchase_refactor.down.sql`:
- Around line 9-12: The rollback currently drops
charge_credit_purchase_credit_grants before restoring legacy columns; instead,
alter charge_credit_purchases to ADD the columns credit_granted_at and
credit_grant_transaction_group_id first, then populate them from
charge_credit_purchase_credit_grants (join on charge_credit_purchase_id ->
charge_credit_purchases.id and copy granted_at -> credit_granted_at and
transaction_group_id -> credit_grant_transaction_group_id), and only after the
UPDATE drop the charge_credit_purchase_credit_grants table; ensure the ALTER
TABLE and UPDATE statements reference charge_credit_purchases and
charge_credit_purchase_credit_grants exactly as named.

In `@tools/migrate/migrations/20260410144542_credit_purchase_refactor.up.sql`:
- Around line 3-27: The migration drops credit_grant_transaction_group_id and
credit_granted_at before populating the new charge_credit_purchase_credit_grants
table, which will lose historical grant data; modify the migration to CREATE the
charge_credit_purchase_credit_grants table first (using the schema shown), then
INSERT rows into charge_credit_purchase_credit_grants selecting id AS
id_for_grant (generate new id if you use a different PK strategy), namespace,
created_at, updated_at, deleted_at, credit_grant_transaction_group_id ->
transaction_group_id, credit_granted_at -> granted_at, and charge_id =
charge_credit_purchases.id for every row where credit_grant_transaction_group_id
IS NOT NULL (or credit_granted_at IS NOT NULL) to backfill existing grants,
ensure uniqueness constraints (namespace+charge_id, charge_id) are satisfied or
deduplicate before inserting, and only after successful backfill DROP the legacy
columns credit_grant_transaction_group_id and credit_granted_at and then proceed
with setting status_detailed NOT NULL.

---

Nitpick comments:
In `@openmeter/billing/charges/creditpurchase/charge.go`:
- Around line 69-70: The Charge struct's Realizations field is a value type
(Realizations) so JSON marshaling emits "realizations": {} even when not
expanded; change the field to be a pointer (*Realizations) or make
Charge-to-response DTO mapping that uses a nullable/omitted field to distinguish
"not loaded" vs "loaded empty". Update the Charge struct declaration
(Realizations) and any code that constructs or serializes Charge (also check the
similar occurrence around the other instance noted at lines 146-147) to either
initialize the pointer only when expanded or map to a response DTO that omits
the realizations key when nil/unset.

In `@openmeter/ent/schema/chargescreditpurchase.go`:
- Around line 117-123: The schema currently allows updates to write-once fields:
add Ent's Immutable() to the field definitions for "transaction_group_id" (the
field.String("transaction_group_id") chain) and "granted_at" (the
field.Time("granted_at") chain) so Ent will enforce append-only semantics; keep
existing SchemaType and NotEmpty() on transaction_group_id and preserve any
other chained modifiers when adding Immutable() to both field builders in
chargescreditpurchase.go.

In `@test/credits/sanity_lifecycle_test.go`:
- Around line 531-543: The test reads
updatedCharge.Realizations.ExternalPaymentSettlement.Status without guarding the
pointer, which can panic; before each s.Equal that inspects .Status (after calls
to Charges.HandleCreditPurchaseExternalPaymentStateTransition), add
s.Require().NotNil(updatedCharge.Realizations.ExternalPaymentSettlement) to
assert the realization exists and fail the test cleanly if it does not.
🪄 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: ed88dbb0-26c0-4ddc-9f5a-9da64fa85853

📥 Commits

Reviewing files that changed from the base of the PR and between 8975a97 and da6a5fd.

⛔ Files ignored due to path filters (27)
  • openmeter/ent/db/chargecreditpurchase.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchase/chargecreditpurchase.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchase/where.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchase_create.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchase_query.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchase_update.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant/chargecreditpurchasecreditgrant.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant/where.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant_create.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant_delete.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant_query.go is excluded by !**/ent/db/**
  • openmeter/ent/db/chargecreditpurchasecreditgrant_update.go is excluded by !**/ent/db/**
  • openmeter/ent/db/client.go is excluded by !**/ent/db/**
  • openmeter/ent/db/cursor.go is excluded by !**/ent/db/**
  • openmeter/ent/db/ent.go is excluded by !**/ent/db/**
  • openmeter/ent/db/entmixinaccessor.go is excluded by !**/ent/db/**
  • openmeter/ent/db/expose.go is excluded by !**/ent/db/**
  • openmeter/ent/db/hook/hook.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/paginate.go is excluded by !**/ent/db/**
  • openmeter/ent/db/predicate/predicate.go is excluded by !**/ent/db/**
  • openmeter/ent/db/runtime.go is excluded by !**/ent/db/**
  • openmeter/ent/db/setorclear.go is excluded by !**/ent/db/**
  • openmeter/ent/db/tx.go is excluded by !**/ent/db/**
  • tools/migrate/migrations/atlas.sum is excluded by !**/*.sum, !**/*.sum
📒 Files selected for processing (18)
  • .agents/skills/charges/SKILL.md
  • api/v3/handlers/customers/credits/convert.go
  • openmeter/billing/charges/creditpurchase/adapter.go
  • openmeter/billing/charges/creditpurchase/adapter/charge.go
  • openmeter/billing/charges/creditpurchase/adapter/creditgrant.go
  • openmeter/billing/charges/creditpurchase/adapter/mapper.go
  • openmeter/billing/charges/creditpurchase/charge.go
  • openmeter/billing/charges/creditpurchase/service/external.go
  • openmeter/billing/charges/creditpurchase/service/invoice.go
  • openmeter/billing/charges/creditpurchase/service/promotional.go
  • openmeter/billing/charges/creditpurchase/statemachine.go
  • openmeter/billing/charges/service/creditpurchase_test.go
  • openmeter/ent/schema/chargescreditpurchase.go
  • openmeter/ledger/chargeadapter/creditpurchase_test.go
  • test/credits/sanity_lifecycle_test.go
  • test/credits/sanity_test.go
  • tools/migrate/migrations/20260410144542_credit_purchase_refactor.down.sql
  • tools/migrate/migrations/20260410144542_credit_purchase_refactor.up.sql

Comment thread .agents/skills/charges/SKILL.md Outdated
Comment thread api/v3/handlers/customers/credits/convert.go
Comment thread openmeter/billing/charges/creditpurchase/adapter/mapper.go
Comment thread openmeter/billing/charges/creditpurchase/service/promotional.go
@turip turip force-pushed the refactor/creditpurchase-shape branch from 6f32375 to 0b2f64c Compare April 10, 2026 16:36
@turip turip enabled auto-merge (squash) April 11, 2026 05:17
Comment thread openmeter/billing/charges/creditpurchase/service/invoice.go Outdated
Signed-off-by: András Tóth <4157749+tothandras@users.noreply.github.com>
@turip turip merged commit 3e39cd0 into main Apr 11, 2026
24 checks passed
@turip turip deleted the refactor/creditpurchase-shape branch April 11, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants