Skip to content

feat(creditpurchase): Add statemachine to externally-settled creaditp…#4471

Open
mark-vass-konghq wants to merge 1 commit into
mainfrom
feat/add-statemachine-to-external-settled-creditpurchase
Open

feat(creditpurchase): Add statemachine to externally-settled creaditp…#4471
mark-vass-konghq wants to merge 1 commit into
mainfrom
feat/add-statemachine-to-external-settled-creditpurchase

Conversation

@mark-vass-konghq

@mark-vass-konghq mark-vass-konghq commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Improved external credit-purchase flow with explicit created → payment-pending → payment-authorized → final transitions and single-step authorize+settle support.
    • Preserves charge state across external purchase realizations and surfaces more detailed payment-phase statuses.
  • Bug Fixes

    • Reject non-positive settlement cost basis with clearer validation errors.
    • More robust error handling and consistent rounding for external purchase and payment flows.
  • Tests

    • Expanded unit tests covering lifecycle paths, rounding, validation, duplicate-authorization guards, and adapter/realization side effects.
  • Documentation

    • Guidance updated for mapping detailed statuses to root meta statuses.

@mark-vass-konghq mark-vass-konghq self-assigned this Jun 2, 2026
@mark-vass-konghq mark-vass-konghq requested a review from a team as a code owner June 2, 2026 09:22
@mark-vass-konghq mark-vass-konghq added release-note/feature Release note: Exciting New Features area/billing labels Jun 2, 2026
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Extracts external credit-purchase realizations into a realizations service, adds an ExternalCreditPurchaseStateMachine to orchestrate initiate→authorize→settle transitions, enforces strictly positive settlement cost basis, refactors handlers to delegate to the state machine, and adds comprehensive tests covering flows and validation.

Changes

External Credit Purchase Refactoring

Layer / File(s) Summary
Settlement validation and status constants
openmeter/billing/charges/creditpurchase/settlement.go, openmeter/billing/charges/creditpurchase/settlement_test.go, openmeter/billing/charges/service/creditpurchase_test.go, test/credits/creditgrant_test.go
GenericSettlement.Validate() now requires strictly positive CostBasis; payment-phase statuses (active.payment.pending, active.payment.authorized) were added and tests updated to assert these new statuses and validation behavior.
Realizations service implementation
openmeter/billing/charges/creditpurchase/service/realizations/service.go
New creditpurchaserealizations.Service with validated Config and methods: InitiateExternalCreditPurchase, AuthorizeExternalPayment, SettleExternalPayment, and AuthorizeAndSettleExternalPayment, each persisting realizations via the adapter and using handler-provided ledger/timing references and optional lineage backfill.
State machine config and service wiring
openmeter/billing/charges/creditpurchase/service/statemachine.go, openmeter/billing/charges/creditpurchase/service/service.go
Adds Realizations *creditpurchaserealizations.Service to StateMachineConfig and internal stateMachine; main New constructs and injects the realizations service and stores it on the returned service instance.
ExternalCreditPurchaseStateMachine implementation
openmeter/billing/charges/creditpurchase/service/external.go
New exported ExternalCreditPurchaseStateMachine and constructor with input validation (requires realizations and external settlement type); wires state transitions so entry actions call realizations and final-state entry dispatches to SettleExternalPayment or AuthorizeAndSettleExternalPayment; preserves ChargeBase across realization calls.
External payment handler refactoring
openmeter/billing/charges/creditpurchase/service/external.go
onExternalCreditPurchase now builds and advances the state machine then dispatches based on initial settlement status; HandleExternalPaymentAuthorized and HandleExternalPaymentSettled run inside transactions and delegate to the state machine (authorize or fire final-state triggers) and return the resulting charge.
Comprehensive test suite and test fixtures
openmeter/billing/charges/creditpurchase/service/external_test.go, test/credits/creditgrant_test.go
Adds tests for created→active activation with credit grant and lineage backfill, currency rounding behavior, routing for provider initial statuses, rejection of non-positive cost basis, sequential and single-step authorize+settle flows, duplicate-authorization guard, plus deterministic fixtures and test doubles for adapter/handler/lineage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • openmeterio/openmeter#4457: Both PRs extend the credit-purchase state machine framework and the current PR builds Realizations wiring on that framework.
  • openmeterio/openmeter#4129: Related refactor of the creditpurchase shapes and adapter split used by the new state machine and realizations service.
  • openmeterio/openmeter#4072: Related in that both touch settlement validation behavior for creditpurchase, though scope differs.

Suggested labels

kind/feature

Suggested reviewers

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

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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 appears to be truncated mid-word ('creaditp…' instead of 'creditpurchase'), making it incomplete and unclear about the full scope of changes. Complete the title by fixing the truncation. Consider: 'feat(creditpurchase): Add statemachine to externally-settled creditpurchase' or similar.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/add-statemachine-to-external-settled-creditpurchase

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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.

@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: 3

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/charges/creditpurchase/service/statemachine.go (1)

35-42: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the repo’s aggregated validation pattern here.

This Validate() still bails on the first failure, so callers miss combined config errors and don’t get the standard nillable validation shape. Since StateMachineConfig just picked up another dependency, it’d be good to switch this over now. As per coding guidelines, "For Validate() error methods in Go, collect all validation issues into var errs []error and return models.NewNillableGenericValidationError(errors.Join(errs...)) instead of returning on the first invalid field".

🤖 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 `@openmeter/billing/charges/creditpurchase/service/statemachine.go` around
lines 35 - 42, StateMachineConfig.Validate currently returns on the first error;
change it to collect all field validation issues into a slice (e.g., var errs
[]error) checking Charge.ID and Adapter (and any newly added deps) and append
errors for each invalid field, then return
models.NewNillableGenericValidationError(errors.Join(errs...)) if len(errs)>0,
otherwise return nil; update the function name StateMachineConfig.Validate and
ensure you import and use errors.Join and
models.NewNillableGenericValidationError to match the repo's aggregated
validation pattern.
🧹 Nitpick comments (1)
openmeter/billing/charges/creditpurchase/service/external_test.go (1)

360-389: ⚡ Quick win

Heads up: this setup is a near-clone of the previous test. 🪞

The charge fixture (lines 367-371) and the externalStateMachineHandler callbacks (lines 374-387) are byte-for-byte identical to TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesPayment (lines 307-311 and 314-327). Only the trigger flow differs (sequential authorize→settle vs. the single authorized-and-settled transition). Pulling the shared charge + handler into a tiny builder would keep the two tests focused on just the behavior that differs.

♻️ Sketch of a shared builder
func newAuthorizeSettleFixture(t *testing.T) (creditpurchase.Charge, *externalStateMachineHandler) {
	t.Helper()
	charge := newExternalStateMachineTestCharge(creditpurchase.StatusActive, alpacadecimal.NewFromFloat(0.5))
	charge.Realizations.CreditGrantRealization = &ledgertransaction.TimedGroupReference{
		GroupReference: ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"},
		Time:           time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
	}
	handler := &externalStateMachineHandler{
		onCreditPurchasePaymentAuthorized: func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) {
			require.Equal(t, creditpurchase.StatusActive, input.Charge.Status)
			require.Nil(t, input.Charge.Realizations.ExternalPaymentSettlement)
			return ledgertransaction.GroupReference{TransactionGroupID: "authorized-ledger-tx"}, nil
		},
		onCreditPurchasePaymentSettled: func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) {
			require.Equal(t, creditpurchase.StatusActive, input.Charge.Status)
			require.NotNil(t, input.Charge.Realizations.ExternalPaymentSettlement)
			require.Equal(t, payment.StatusAuthorized, input.Charge.Realizations.ExternalPaymentSettlement.Status)
			require.Equal(t, "authorized-ledger-tx", input.Charge.Realizations.ExternalPaymentSettlement.Authorized.TransactionGroupID)
			return ledgertransaction.GroupReference{TransactionGroupID: "settled-ledger-tx"}, nil
		},
	}
	return charge, handler
}

As per coding guidelines: "Avoid redundant test helpers and duplicate setup paths; parameterize helpers over maintaining near-identical ones."

🤖 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 `@openmeter/billing/charges/creditpurchase/service/external_test.go` around
lines 360 - 389, The two tests
TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesInSingleTransition and
TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesPayment duplicate the
same charge setup and externalStateMachineHandler callbacks; extract that shared
setup into a small helper (e.g. newAuthorizeSettleFixture) that returns the
creditpurchase.Charge and *externalStateMachineHandler using the existing
newExternalStateMachineTestCharge, externalStateMachineHandler (with
onCreditPurchasePaymentAuthorized and onCreditPurchasePaymentSettled closures),
then update both tests to call the helper and reuse lineageService/realizations
creation (externalStateMachineLineage, newExternalStateMachineRealizations) so
each test only sets the differing trigger flow.
🤖 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 `@openmeter/billing/charges/creditpurchase/service/external.go`:
- Around line 122-138: In AuthorizeExternalPayment on
ExternalCreditPurchaseStateMachine the branch that checks
s.Charge.Realizations.ExternalPaymentSettlement != nil calls
s.Realizations.AuthorizeExternalPayment but discards the returned charge,
leaving s.Charge stale; change that branch to capture the returned charge (from
Realizations.AuthorizeExternalPayment(ctx, s.Charge)), handle the error as
currently done, and assign the returned charge to s.Charge before returning so
the state machine retains the updated charge (refer to AuthorizeExternalPayment,
s.Charge, Realizations.AuthorizeExternalPayment, ExternalPaymentSettlement).
- Around line 44-58: The switch currently groups
creditpurchase.SettledInitialPaymentSettlementStatus with AuthorizedAndSettled
and fires triggerExternalPaymentAuthorizedAndSettled, which can replay
authorization when only settlement is requested; split those cases so
SettledInitialPaymentSettlementStatus calls stateMachine.FireAndActivate(ctx,
triggerExternalPaymentSettled) (the settle-only trigger) and keep
creditpurchase.AuthorizedAndSettledInitialPaymentSettlementStatus using
stateMachine.FireAndActivate(ctx, triggerExternalPaymentAuthorizedAndSettled),
leaving the Created and Authorized cases unchanged.

In `@openmeter/billing/charges/creditpurchase/service/realizations/service.go`:
- Around line 30-46: The Config.Validate function currently aggregates errors
into errs but returns errors.Join(errs...), so callers won't receive a
models.GenericValidationError; change the return to wrap the joined errors with
models.NewNillableGenericValidationError by returning
models.NewNillableGenericValidationError(errors.Join(errs...)) in the
Config.Validate method (referencing Config.Validate and
models.NewNillableGenericValidationError to locate and update the return).

---

Outside diff comments:
In `@openmeter/billing/charges/creditpurchase/service/statemachine.go`:
- Around line 35-42: StateMachineConfig.Validate currently returns on the first
error; change it to collect all field validation issues into a slice (e.g., var
errs []error) checking Charge.ID and Adapter (and any newly added deps) and
append errors for each invalid field, then return
models.NewNillableGenericValidationError(errors.Join(errs...)) if len(errs)>0,
otherwise return nil; update the function name StateMachineConfig.Validate and
ensure you import and use errors.Join and
models.NewNillableGenericValidationError to match the repo's aggregated
validation pattern.

---

Nitpick comments:
In `@openmeter/billing/charges/creditpurchase/service/external_test.go`:
- Around line 360-389: The two tests
TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesInSingleTransition and
TestExternalCreditPurchaseStateMachineAuthorizesAndSettlesPayment duplicate the
same charge setup and externalStateMachineHandler callbacks; extract that shared
setup into a small helper (e.g. newAuthorizeSettleFixture) that returns the
creditpurchase.Charge and *externalStateMachineHandler using the existing
newExternalStateMachineTestCharge, externalStateMachineHandler (with
onCreditPurchasePaymentAuthorized and onCreditPurchasePaymentSettled closures),
then update both tests to call the helper and reuse lineageService/realizations
creation (externalStateMachineLineage, newExternalStateMachineRealizations) so
each test only sets the differing trigger flow.
🪄 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: 13e0a41c-0fcc-49f4-9126-ce5497ebe586

📥 Commits

Reviewing files that changed from the base of the PR and between 8bca288 and 690b5c8.

📒 Files selected for processing (8)
  • openmeter/billing/charges/creditpurchase/service/external.go
  • openmeter/billing/charges/creditpurchase/service/external_test.go
  • openmeter/billing/charges/creditpurchase/service/realizations/service.go
  • openmeter/billing/charges/creditpurchase/service/service.go
  • openmeter/billing/charges/creditpurchase/service/statemachine.go
  • openmeter/billing/charges/creditpurchase/settlement.go
  • openmeter/billing/charges/creditpurchase/settlement_test.go
  • openmeter/billing/charges/service/creditpurchase_test.go

Comment thread openmeter/billing/charges/creditpurchase/service/external.go Outdated
Comment thread openmeter/billing/charges/creditpurchase/service/external.go Outdated
@mark-vass-konghq mark-vass-konghq force-pushed the feat/add-statemachine-to-external-settled-creditpurchase branch from 690b5c8 to 636dfda Compare June 2, 2026 10:47
case creditpurchase.CreatedInitialPaymentSettlementStatus:
return charge, nil
case creditpurchase.AuthorizedInitialPaymentSettlementStatus:
if err := stateMachine.AuthorizeExternalPayment(ctx); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not do this trigger based?

Comment on lines +15 to +16
triggerExternalPaymentSettled meta.Trigger = "external_payment_settled"
triggerExternalPaymentAuthorizedAndSettled meta.Trigger = "external_payment_authorized_and_settled"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let's instead use what's in billing:

// TriggerPaid is used to signify that the invoice has been paid
	TriggerPaid InvoiceTrigger = "trigger_paid"
	// TriggerAuthorized is used to signify that the invoice has an authorized payment method/funds hold
	TriggerAuthorized InvoiceTrigger = "trigger_authorized"

TriggerPaid InvoiceTrigger = "trigger_paid"

I don't think it makes sense to differentiate between external settled and invoiced triggers as they will have seperate state machines.

Comment on lines +97 to +100
s.Configure(creditpurchase.StatusActive).
OnEntryFrom(meta.TriggerNext, statelessx.EntryFunc(s.InitiateExternalCreditPurchaseFromCreated)).
Permit(triggerExternalPaymentSettled, creditpurchase.StatusFinal).
Permit(triggerExternalPaymentAuthorizedAndSettled, creditpurchase.StatusFinal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's have substates for active.payment.authorized, active.payment.pending

// - the external state machine advances until stable
// then:
// - it grants credits, backfills lineage, and persists the active status
stateMachine, charge, adapter, lineageService := newExternalStateMachineTestMachine(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

use struct return instead please.

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

Caution

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

⚠️ Outside diff range comments (1)
openmeter/billing/charges/creditpurchase/service/external.go (1)

21-50: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the requested initial status before advancing the machine.

Line 32 can already initiate the external purchase before Line 40 rejects an unsupported InitialStatus. If invalid data slips through, this returns an error after the side effect has happened. Please resolve externalInitialPaymentTrigger(...) first and fail fast before AdvanceUntilStateStable(ctx).

💡 Suggested change
 	targetStatus := externalCreditPurchaseSettlement.InitialStatus
+
+	trigger, hasTrigger, err := externalInitialPaymentTrigger(targetStatus)
+	if err != nil {
+		return creditpurchase.Charge{}, err
+	}
 
 	stateMachine, err := NewExternalCreditPurchaseStateMachine(StateMachineConfig{
 		Charge:       charge,
 		Adapter:      s.adapter,
 		Realizations: s.realizations,
@@
 	if advancedCharge != nil {
 		charge = *advancedCharge
 	}
-
-	trigger, hasTrigger, err := externalInitialPaymentTrigger(targetStatus)
-	if err != nil {
-		return creditpurchase.Charge{}, err
-	}
 
 	if !hasTrigger {
 		return charge, nil
 	}
🤖 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 `@openmeter/billing/charges/creditpurchase/service/external.go` around lines 21
- 50, Validate the requested initial status (call
externalInitialPaymentTrigger(targetStatus) and handle its error/hasTrigger
result) before advancing the state machine so we fail fast on unsupported
InitialStatus; specifically, move the externalInitialPaymentTrigger call (and
the early return when !hasTrigger or error) to occur prior to calling
stateMachine.AdvanceUntilStateStable(ctx) (or even before
NewExternalCreditPurchaseStateMachine if preferred), then only proceed to
AdvanceUntilStateStable and stateMachine.FireAndActivate when the trigger is
valid.
🧹 Nitpick comments (1)
openmeter/billing/charges/creditpurchase/service/external_test.go (1)

61-72: 💤 Low value

Tiny optional thought on the float assertions.

Since you've already rounded 100.123100.12, the equality checks like Line 72 could read with a plain literal (require.Equal(t, 100.12, charge.Intent.CreditAmount.InexactFloat64())). That said, expectedAmount is genuinely reused in the lineage matcher on Line 91 for .Equal(...), so keeping it as a decimal here is perfectly defensible — totally your call.

As per coding guidelines: "When asserting alpacadecimal.Decimal equality in tests, prefer require.Equal(t, expectedFloat64, actual.InexactFloat64()) ... use simple float64(5)-style literals over verbose decimal construction".

🤖 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 `@openmeter/billing/charges/creditpurchase/service/external_test.go` around
lines 61 - 72, The test uses an alpacadecimal.Decimal variable expectedAmount
for asserting the InexactFloat64 value, but per guidelines prefer a plain
float64 literal when asserting Decimal.InexactFloat64(); update the assertion
that compares charge.Intent.CreditAmount.InexactFloat64() to use a literal
(e.g., 100.12) instead of expectedAmount.InexactFloat64(), leaving
expectedAmount in place for its later use in the lineage matcher; modify the
assertion around require.Equal(...) that references expectedAmount and
charge.Intent.CreditAmount.InexactFloat64() accordingly.
🤖 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.

Outside diff comments:
In `@openmeter/billing/charges/creditpurchase/service/external.go`:
- Around line 21-50: Validate the requested initial status (call
externalInitialPaymentTrigger(targetStatus) and handle its error/hasTrigger
result) before advancing the state machine so we fail fast on unsupported
InitialStatus; specifically, move the externalInitialPaymentTrigger call (and
the early return when !hasTrigger or error) to occur prior to calling
stateMachine.AdvanceUntilStateStable(ctx) (or even before
NewExternalCreditPurchaseStateMachine if preferred), then only proceed to
AdvanceUntilStateStable and stateMachine.FireAndActivate when the trigger is
valid.

---

Nitpick comments:
In `@openmeter/billing/charges/creditpurchase/service/external_test.go`:
- Around line 61-72: The test uses an alpacadecimal.Decimal variable
expectedAmount for asserting the InexactFloat64 value, but per guidelines prefer
a plain float64 literal when asserting Decimal.InexactFloat64(); update the
assertion that compares charge.Intent.CreditAmount.InexactFloat64() to use a
literal (e.g., 100.12) instead of expectedAmount.InexactFloat64(), leaving
expectedAmount in place for its later use in the lineage matcher; modify the
assertion around require.Equal(...) that references expectedAmount and
charge.Intent.CreditAmount.InexactFloat64() accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5487f99e-3031-4fca-8b01-949a03561e1d

📥 Commits

Reviewing files that changed from the base of the PR and between 636dfda and 4eee075.

⛔ Files ignored due to path filters (2)
  • openmeter/ent/db/chargecreditpurchase/chargecreditpurchase.go is excluded by !**/ent/db/**
  • openmeter/ent/db/migrate/schema.go is excluded by !**/ent/db/**
📒 Files selected for processing (5)
  • openmeter/billing/charges/creditpurchase/service/external.go
  • openmeter/billing/charges/creditpurchase/service/external_test.go
  • openmeter/billing/charges/creditpurchase/statemachine.go
  • openmeter/billing/charges/service/creditpurchase_test.go
  • test/credits/creditgrant_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • openmeter/billing/charges/service/creditpurchase_test.go


return charge, nil
})
func externalInitialPaymentTrigger(status creditpurchase.InitialPaymentSettlementStatus) (meta.Trigger, bool, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

meta.Trigger, error signature is enough, let's treat empty trigger (== "") as no trigger available.

Comment on lines +137 to +141
switch s.Charge.Status {
case creditpurchase.StatusActive, creditpurchase.StatusActivePaymentPending, creditpurchase.StatusActivePaymentAuthorized:
default:
return fmt.Errorf("%w: authorize external payment [status=%s,id=%s]", chargestatemachine.ErrUnsupportedOperation, s.Charge.Status, s.Charge.ID)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why do we need this?

the realization must error if we have already authorized the payment, right?

}
currentBase := s.Charge.ChargeBase
eventCharge := s.Charge
eventCharge.Status = creditpurchase.StatusActivePaymentPending

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

state machine should not be manipulated here, it's stateless' responsibility, why do we need this status change?

func (s *ExternalCreditPurchaseStateMachine) SettleExternalPayment(ctx context.Context) error {
currentBase := s.Charge.ChargeBase
eventCharge := s.Charge
eventCharge.Status = creditpurchase.StatusActivePaymentAuthorized

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as with authorize, let's not do this, instead let's use different checks or statemachine states/triggering mecnahnisms as needed.

CreatedInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "created"
AuthorizedInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "authorized"
SettledInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "settled"
AuthorizedAndSettledInitialPaymentSettlementStatus InitialPaymentSettlementStatus = "authorized_and_settled"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If initial payment status is settled, that means authorized -> settled transitions. Let's not add this redundant state.

}

return meta.ChargeStatus(s), nil
switch s {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

there's a helper for this, feel free to update the charges skill, check flatfee or usagebased.

please never rely on explicit state enumerations outside of the statemachine transition matrix as these will diverge over time.

})
s.Require().NoError(err)
s.Equal(creditpurchase.StatusActive, grant.Status)
s.Equal(creditpurchase.StatusActivePaymentAuthorized, grant.Status)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let's add integration level test for purchasing $1 credits with the costbasis of 0.005. I would expect the allocation to happen, and authroized/settled flow to fully work.

@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)
openmeter/billing/charges/meta/charge.go (1)

130-137: ⚡ Quick win

Add a docstring for DetailedStatusToMetaStatus.

Quick readability win: this helper encodes a subtle business contract (detailed status prefix mapping + invalid fallback behavior). A short doc comment will make call sites less error-prone.

As per coding guidelines: "Add a docstring to domain helpers when the name compresses important business semantics that are easy to misread at call sites; explain the observable business contract and why excluded cases are excluded".

🤖 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 `@openmeter/billing/charges/meta/charge.go` around lines 130 - 137, Add a
docstring for DetailedStatusToMetaStatus that succinctly documents the business
contract: it derives a meta ChargeStatus by taking the prefix before the first
'.' in the provided detailed status (e.g., "created.whatever" ->
ChargeStatusCreated), validates the resulting ChargeStatus via
metaStatus.Validate(), and on validation failure returns ChargeStatusCreated
along with an error; also note that this intentionally ignores suffixes after
the dot and that callers should pass non-empty detailed statuses or handle the
error. Include the function name DetailedStatusToMetaStatus and mention the use
of strings.SplitN and metaStatus.Validate to make the behavior and fallback
explicit.
🤖 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 `@openmeter/billing/charges/meta/charge.go`:
- Around line 130-137: Add a docstring for DetailedStatusToMetaStatus that
succinctly documents the business contract: it derives a meta ChargeStatus by
taking the prefix before the first '.' in the provided detailed status (e.g.,
"created.whatever" -> ChargeStatusCreated), validates the resulting ChargeStatus
via metaStatus.Validate(), and on validation failure returns ChargeStatusCreated
along with an error; also note that this intentionally ignores suffixes after
the dot and that callers should pass non-empty detailed statuses or handle the
error. Include the function name DetailedStatusToMetaStatus and mention the use
of strings.SplitN and metaStatus.Validate to make the behavior and fallback
explicit.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 826da269-ee96-4d39-bf5f-8a26910cc5d9

📥 Commits

Reviewing files that changed from the base of the PR and between 3f3d924 and b593c22.

📒 Files selected for processing (9)
  • .agents/skills/charges/SKILL.md
  • openmeter/billing/charges/creditpurchase/service/external.go
  • openmeter/billing/charges/creditpurchase/service/external_test.go
  • openmeter/billing/charges/creditpurchase/settlement.go
  • openmeter/billing/charges/creditpurchase/statemachine.go
  • openmeter/billing/charges/flatfee/statemachine.go
  • openmeter/billing/charges/meta/charge.go
  • openmeter/billing/charges/usagebased/statemachine.go
  • test/credits/creditgrant_test.go
✅ Files skipped from review due to trivial changes (1)
  • .agents/skills/charges/SKILL.md

Comment on lines 161 to 170
charge, err := s.Realizations.AuthorizeExternalPayment(ctx, s.Charge)
if err != nil {
return err
}

if paymentSettlement.Status != payment.StatusAuthorized {
return creditpurchase.Charge{}, payment.ErrPaymentAlreadySettled.
WithAttrs(charge.ErrorAttributes()).
WithAttrs(paymentSettlement.ErrorAttributes())
}
charge, err = s.Realizations.SettleExternalPayment(ctx, charge)
if err != nil {
return err
}

eventAt := clock.Now()
ledgerTransactionGroupReference, err := s.handler.OnCreditPurchasePaymentSettled(ctx, creditpurchase.PaymentEventInput{
Charge: charge,
EventAt: eventAt,
})
if err != nil {
return creditpurchase.Charge{}, err
}
charge.ChargeBase = currentBase
s.Charge = charge
return nil
}

paymentSettlement.Settled = &ledgertransaction.TimedGroupReference{
GroupReference: ledgerTransactionGroupReference,
Time: eventAt,
}
func (s *ExternalCreditPurchaseStateMachine) HandleExternalPaymentPaid(ctx context.Context) error {
if s.Charge.Realizations.ExternalPaymentSettlement != nil {
return s.SettleExternalPayment(ctx)
}

paymentSettlement.Status = payment.StatusSettled
return s.AuthorizeAndSettleExternalPayment(ctx)
}

func (s *service) newExternalCreditPurchaseStateMachine(charge creditpurchase.Charge) (*ExternalCreditPurchaseStateMachine, error) {
return NewExternalCreditPurchaseStateMachine(StateMachineConfig{
Charge: charge,
Adapter: s.adapter,
Realizations: s.realizations,
})
}

paymentSettlement, err = s.adapter.UpdateExternalPayment(ctx, paymentSettlement)
func (s *service) HandleExternalPaymentAuthorized(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.Charge, error) {
stateMachine, err := s.newExternalCreditPurchaseStateMachine(charge)
if err != nil {
return creditpurchase.Charge{}, err
}

charge.Realizations.ExternalPaymentSettlement = &paymentSettlement
if err := stateMachine.FireAndActivate(ctx, billing.TriggerAuthorized); err != nil {
return creditpurchase.Charge{}, err
}

// Let's update the charge status to final
charge.Status = creditpurchase.StatusFinal
return stateMachine.GetCharge(), nil
})
}

updatedBase, err := s.adapter.UpdateCharge(ctx, charge.ChargeBase)
func (s *service) HandleExternalPaymentSettled(ctx context.Context, charge creditpurchase.Charge) (creditpurchase.Charge, error) {
return transaction.Run(ctx, s.adapter, func(ctx context.Context) (creditpurchase.Charge, error) {
stateMachine, err := s.newExternalCreditPurchaseStateMachine(charge)
if err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

there's a helper for this in realizations.


s.Configure(creditpurchase.StatusActive).
Permit(billing.TriggerAuthorized, creditpurchase.StatusActivePaymentAuthorized).
Permit(billing.TriggerPaid, creditpurchase.StatusFinal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not OnActive(s.InitiateExternalCreditPurchaseFromCreated) and remove l 107?

Natural progression can be handled in OnActive.

Permit(billing.TriggerPaid, creditpurchase.StatusFinal)

s.Configure(creditpurchase.StatusActivePaymentAuthorized).
OnEntryFrom(billing.TriggerAuthorized, statelessx.EntryFunc(s.AuthorizeExternalPayment)).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not on active?

Comment on lines +115 to +116
s.Configure(creditpurchase.StatusFinal).
OnEntryFrom(billing.TriggerPaid, statelessx.EntryFunc(s.HandleExternalPaymentPaid))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
s.Configure(creditpurchase.StatusFinal).
OnEntryFrom(billing.TriggerPaid, statelessx.EntryFunc(s.HandleExternalPaymentPaid))
s.Configure(creditpurchase.StatusActivePaid).
Permit(billing.TriggerNext, creditpurchase.StatusFinal)
OnActive(s.HandleExternalPaymentPaid)
s.Configure(creditpurchase.StatusFinal).
OnEntryFrom(billing.TriggerPaid, statelessx.EntryFunc(s.HandleExternalPaymentPaid))

I think it's easier to read that when in paid status execute that callback, then proceed to the next state.

s.Configure(creditpurchase.StatusActivePaymentPending).
OnEntryFrom(meta.TriggerNext, statelessx.EntryFunc(s.InitiateExternalCreditPurchaseFromCreated)).
Permit(billing.TriggerAuthorized, creditpurchase.StatusActivePaymentAuthorized).
Permit(billing.TriggerPaid, creditpurchase.StatusFinal)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
Permit(billing.TriggerPaid, creditpurchase.StatusFinal)
Permit(billing.TriggerPaid, creditpurchase.StatusActivePaymentAuthorizedAndPaid)

Then we can use active there to invoke the right callback instead of magicly deciding if authorization should happen.

Comment on lines +662 to +664
if h.onCreditPurchaseInitiated == nil {
return ledgertransaction.GroupReference{}, nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

let's return an error or panic here, as this is a not expected call.

Comment on lines +656 to +658
onCreditPurchaseInitiated func(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error)
onCreditPurchasePaymentAuthorized func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error)
onCreditPurchasePaymentSettled func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use mock.Mock instead. It will fail the testcase if there's an unexpected call.

Comment on lines +176 to +181
onCreditPurchaseInitiated: func(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) {
initiatedCalls++
require.Equal(t, creditpurchase.StatusActivePaymentPending, charge.Status)
require.Nil(t, charge.Realizations.CreditGrantRealization)
require.Nil(t, charge.Realizations.ExternalPaymentSettlement)
return ledgertransaction.GroupReference{TransactionGroupID: "initiated-ledger-tx"}, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not mock.Mock + On()...Run().

Comment on lines +226 to +235
s.CreditPurchaseTestHandler.onCreditPurchaseInitiated = func(ctx context.Context, charge creditpurchase.Charge) (ledgertransaction.GroupReference, error) {
callbackCalls++
return ledgertransaction.GroupReference{TransactionGroupID: "unexpected-initiated-ledger-tx"}, nil
}
s.CreditPurchaseTestHandler.onCreditPurchasePaymentAuthorized = func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) {
callbackCalls++
return ledgertransaction.GroupReference{TransactionGroupID: "unexpected-authorized-ledger-tx"}, nil
}
s.CreditPurchaseTestHandler.onCreditPurchasePaymentSettled = func(ctx context.Context, input creditpurchase.PaymentEventInput) (ledgertransaction.GroupReference, error) {
callbackCalls++

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

these are not needed as they will return error if called without a callback set, so the testcase will fail.

assert.Equal(t, initiatedCallback.id, charge.Realizations.CreditGrantRealization.GroupReference.TransactionGroupID)
assert.Nil(t, charge.Realizations.ExternalPaymentSettlement)
assert.Equal(t, creditpurchase.StatusActive, charge.Status, "charge status should be active")
assert.Equal(t, creditpurchase.StatusFinal, charge.Status, "charge status should be final")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should not be final state, we should only traverse to final after the callback has been invoked, not before.

So this should be active.payment.authorized.

Comment on lines +105 to +107
s.Configure(creditpurchase.StatusActive).
Permit(billing.TriggerAuthorized, creditpurchase.StatusActivePaymentAuthorized).
Permit(billing.TriggerPaid, creditpurchase.StatusActivePaymentAuthorized)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Active should only allow Next, or we can skip initiate credit purchase.

return s.settleAfterAuthorization
}

func (s *ExternalCreditPurchaseStateMachine) InitiateExternalCreditPurchaseFromCreated(ctx context.Context) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

please use some name that describes what it does, this seems like a bunch of random words.

func (s *ExternalCreditPurchaseStateMachine) FireAndAdvanceUntilStateStable(ctx context.Context, trigger meta.Trigger) (creditpurchase.Charge, error) {
// Direct paid events still authorize first; the following next transition
// settles while leaving active.payment.authorized, before final is persisted.
s.settleAfterAuthorization = trigger == billing.TriggerPaid &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

don't: trigger is ephemeral, and this makes FireAndAdvanceUtilStable a custom implementation.

Comment on lines +117 to +123
Permit(meta.TriggerNext, creditpurchase.StatusFinal, statelessx.BoolFn(s.ShouldSettleAfterAuthorization)).
OnExitWith(billing.TriggerPaid, func(ctx context.Context, _ ...any) error {
return s.SettleExternalPayment(ctx)
}).
OnExitWith(meta.TriggerNext, func(ctx context.Context, _ ...any) error {
return s.SettleExternalPayment(ctx)
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is super hard to read, let's refactor it together.

@mark-vass-konghq mark-vass-konghq force-pushed the feat/add-statemachine-to-external-settled-creditpurchase branch from 5f822c7 to 30686e7 Compare June 8, 2026 15:50
@mark-vass-konghq mark-vass-konghq force-pushed the feat/add-statemachine-to-external-settled-creditpurchase branch from 30686e7 to 5b692e8 Compare June 8, 2026 15:51
@mark-vass-konghq mark-vass-konghq force-pushed the feat/add-statemachine-to-external-settled-creditpurchase branch from 5b692e8 to 77ab79c Compare June 9, 2026 08:44
@mark-vass-konghq mark-vass-konghq force-pushed the feat/add-statemachine-to-external-settled-creditpurchase branch from 77ab79c to ae425f3 Compare June 9, 2026 09:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/billing release-note/feature Release note: Exciting New Features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants