Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .agents/skills/charges/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,24 @@ Current expected behavior:
- ledger handlers may still defensively tolerate zero and return `ledgertransaction.GroupReference{}`
- when a service proceeds with non-zero invoice accrual, it must require a non-empty transaction group reference before storing accrued usage

## HTTP/API Conversion

Credit-purchase charges have an API/domain enum mismatch for promotional grants.

Rules:

- in the billing domain, promotional credit grants are represented as `creditpurchase.SettlementTypePromotional`
- in the v3 customer credits API, the same case is represented as `funding_method=none`
- v3 API responses for promotional grants must omit the `purchase` block entirely
- conversion code in `api/v3/handlers/customers/credits` must map this case explicitly instead of treating `promotional` as an unsupported settlement type

Important files:

- `api/v3/handlers/customers/credits/convert.go`
- `openmeter/billing/charges/creditpurchase/settlement.go`
- `openmeter/billing/creditgrant/service/service.go`
- `api/spec/packages/aip/src/customers/credits/grant.tsp`

## Realization Helper Subpackages

Use small type-specific realization helper subpackages to keep charge services and state machines from becoming kitchen-sink orchestration layers.
Expand Down
3 changes: 3 additions & 0 deletions api/v3/handlers/customers/credits/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ func toAPICreditGrantPurchase(charge creditpurchase.Charge) (*creditGrantPurchas
SettlementStatus: &settlementStatus,
}, nil

case creditpurchase.SettlementTypePromotional:
return nil, nil

default:
return nil, fmt.Errorf("invalid settlement type: %s", settlement.Type())
}
Expand Down
50 changes: 50 additions & 0 deletions api/v3/handlers/customers/credits/convert_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package customerscredits

import (
"testing"
"time"

"github.com/alpacahq/alpacadecimal"
"github.com/stretchr/testify/require"

api "github.com/openmeterio/openmeter/api/v3"
"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/models"
)

func TestToAPIBillingCreditGrantPromotional(t *testing.T) {
now := time.Date(2026, time.April, 17, 10, 0, 0, 0, time.UTC)

charge := creditpurchase.Charge{
ChargeBase: creditpurchase.ChargeBase{
ManagedResource: meta.ManagedResource{
NamespacedModel: models.NamespacedModel{
Namespace: "ns",
},
ManagedModel: models.ManagedModel{
CreatedAt: now,
UpdatedAt: now,
},
ID: "grant-1",
},
Intent: creditpurchase.Intent{
Intent: meta.Intent{
Name: "Promo credits",
CustomerID: "cust-1",
Currency: currencyx.Code("USD"),
},
CreditAmount: alpacadecimal.RequireFromString("25"),
Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}),
},
Status: creditpurchase.StatusActive,
},
}

grant, err := toAPIBillingCreditGrant(charge)
require.NoError(t, err)
require.Equal(t, api.BillingCreditFundingMethodNone, grant.FundingMethod)
require.Nil(t, grant.Purchase)
require.Equal(t, "25", grant.Amount)
}
30 changes: 26 additions & 4 deletions openmeter/billing/creditgrant/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,37 @@ func (s *service) Create(ctx context.Context, input creditgrant.CreateInput) (cr
// Build the credit purchase intent
intent := toIntent(input)

result, err := s.creditPurchaseService.Create(ctx, creditpurchase.CreateInput{
result, err := s.chargesService.Create(ctx, charges.CreateInput{
Namespace: input.Namespace,
Intent: intent,
Intents: charges.ChargeIntents{charges.NewChargeIntent(intent)},
})
if err != nil {
return creditpurchase.Charge{}, fmt.Errorf("create credit purchase charge: %w", err)
return creditpurchase.Charge{}, fmt.Errorf("create credit grant charge: %w", err)
}

return result.Charge, nil
if len(result) != 1 {
return creditpurchase.Charge{}, fmt.Errorf("expected 1 created charge, got %d", len(result))
}

createdChargeID, err := result[0].GetChargeID()
if err != nil {
return creditpurchase.Charge{}, fmt.Errorf("get created charge id: %w", err)
}

charge, err := s.chargesService.GetByID(ctx, charges.GetByIDInput{
ChargeID: createdChargeID,
Expands: meta.Expands{meta.ExpandRealizations},
})
if err != nil {
return creditpurchase.Charge{}, fmt.Errorf("get created credit grant charge: %w", err)
}

cpCharge, err := charge.AsCreditPurchaseCharge()
if err != nil {
return creditpurchase.Charge{}, fmt.Errorf("charge is not a credit purchase: %w", err)
}

return cpCharge, nil
}

func (s *service) Get(ctx context.Context, input creditgrant.GetInput) (creditpurchase.Charge, error) {
Expand Down
Loading
Loading