diff --git a/.agents/skills/charges/SKILL.md b/.agents/skills/charges/SKILL.md index 89ad106b6b..45ea746c74 100644 --- a/.agents/skills/charges/SKILL.md +++ b/.agents/skills/charges/SKILL.md @@ -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. diff --git a/api/v3/handlers/customers/credits/convert.go b/api/v3/handlers/customers/credits/convert.go index bec6155ac1..c3512e01c0 100644 --- a/api/v3/handlers/customers/credits/convert.go +++ b/api/v3/handlers/customers/credits/convert.go @@ -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()) } diff --git a/api/v3/handlers/customers/credits/convert_test.go b/api/v3/handlers/customers/credits/convert_test.go new file mode 100644 index 0000000000..c1bcbd6d70 --- /dev/null +++ b/api/v3/handlers/customers/credits/convert_test.go @@ -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) +} diff --git a/openmeter/billing/creditgrant/service/service.go b/openmeter/billing/creditgrant/service/service.go index 43d6be19e1..fb85e6388d 100644 --- a/openmeter/billing/creditgrant/service/service.go +++ b/openmeter/billing/creditgrant/service/service.go @@ -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) { diff --git a/test/credits/creditgrant_test.go b/test/credits/creditgrant_test.go new file mode 100644 index 0000000000..8ff458c0eb --- /dev/null +++ b/test/credits/creditgrant_test.go @@ -0,0 +1,281 @@ +package credits + +import ( + "context" + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/stretchr/testify/suite" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + creditpurchaseadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/adapter" + creditpurchaseservice "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase/service" + lineageadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/adapter" + lineageservice "github.com/openmeterio/openmeter/openmeter/billing/charges/lineage/service" + metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter" + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/payment" + creditgrant "github.com/openmeterio/openmeter/openmeter/billing/creditgrant" + creditgrantservice "github.com/openmeterio/openmeter/openmeter/billing/creditgrant/service" + "github.com/openmeterio/openmeter/openmeter/customer" + ledgerchargeadapter "github.com/openmeterio/openmeter/openmeter/ledger/chargeadapter" + omtestutils "github.com/openmeterio/openmeter/openmeter/testutils" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/datetime" + billingtest "github.com/openmeterio/openmeter/test/billing" +) + +func TestCreditGrantTestSuite(t *testing.T) { + suite.Run(t, new(CreditGrantTestSuite)) +} + +type CreditGrantTestSuite struct { + CreditsTestSuite + + CreditPurchaseService creditpurchase.Service + CreditGrantService creditgrant.Service +} + +func (s *CreditGrantTestSuite) SetupSuite() { + s.CreditsTestSuite.SetupSuite() + + logger := omtestutils.NewLogger(s.T()) + metaAdapter, err := metaadapter.New(metaadapter.Config{ + Client: s.DBClient, + Logger: logger, + }) + s.Require().NoError(err) + + lineageAdapter, err := lineageadapter.New(lineageadapter.Config{ + Client: s.DBClient, + }) + s.Require().NoError(err) + + lineageService, err := lineageservice.New(lineageservice.Config{ + Adapter: lineageAdapter, + }) + s.Require().NoError(err) + + creditPurchaseAdapter, err := creditpurchaseadapter.New(creditpurchaseadapter.Config{ + Client: s.DBClient, + Logger: logger, + MetaAdapter: metaAdapter, + }) + s.Require().NoError(err) + + s.CreditPurchaseService, err = creditpurchaseservice.New(creditpurchaseservice.Config{ + Adapter: creditPurchaseAdapter, + Handler: ledgerchargeadapter.NewCreditPurchaseHandler(s.Ledger, s.LedgerResolver, s.LedgerAccountService), + Lineage: lineageService, + MetaAdapter: metaAdapter, + }) + s.Require().NoError(err) + + svc, err := creditgrantservice.New(creditgrantservice.Config{ + CreditPurchaseService: s.CreditPurchaseService, + ChargesService: s.Charges, + CustomerService: s.CustomerService, + }) + s.Require().NoError(err) + + s.CreditGrantService = svc +} + +func (s *CreditGrantTestSuite) TestCreateInvoiceFundedCreatesInvoiceArtifacts() { + ctx := context.Background() + ns := s.GetUniqueNamespace("creditgrant-service-invoice-funded") + + customInvoicing := s.SetupCustomInvoicing(ns) + cust := s.createLedgerBackedCustomer(ns, "test-subject") + + _ = s.ProvisionBillingProfile(ctx, ns, customInvoicing.App.GetID(), + billingtest.WithProgressiveBilling(), + billingtest.WithCollectionInterval(datetime.MustParseDuration(s.T(), "PT1H")), + billingtest.WithManualApproval(), + ) + + now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:53Z", time.UTC).AsTime() + clock.SetTime(now) + + grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ + Namespace: ns, + CustomerID: cust.ID, + Name: "$10.00 grant for $10.00 charge", + Description: lo.ToPtr("A $10.00 grant for $10.00 charge available immediately on grant."), + Currency: USD, + Amount: alpacadecimal.NewFromInt(10), + Priority: lo.ToPtr(int16(10)), + FundingMethod: creditgrant.FundingMethodInvoice, + Purchase: &creditgrant.PurchaseTerms{ + Currency: USD, + PerUnitCostBasis: lo.ToPtr(alpacadecimal.NewFromInt(1)), + }, + }) + s.Require().NoError(err) + s.Equal(creditpurchase.SettlementTypeInvoice, grant.Intent.Settlement.Type()) + s.Equal(creditpurchase.StatusActive, grant.Status) + s.NotNil(grant.Realizations.CreditGrantRealization) + + standardInvoices, err := s.BillingService.ListStandardInvoices(ctx, billing.ListStandardInvoicesInput{ + Namespaces: []string{ns}, + Expand: billing.StandardInvoiceExpandAll, + }) + s.Require().NoError(err) + s.Len(standardInvoices.Items, 1) + + invoice := standardInvoices.Items[0] + s.Equal(cust.ID, invoice.Customer.CustomerID) + s.Len(invoice.Lines.OrEmpty(), 1) + s.Equal(grant.ID, *invoice.Lines.OrEmpty()[0].ChargeID) + + gatheringInvoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ + Namespaces: []string{ns}, + Customers: []string{cust.ID}, + Expand: []billing.GatheringInvoiceExpand{billing.GatheringInvoiceExpandLines}, + }) + s.Require().NoError(err) + s.Len(gatheringInvoices.Items, 0) +} + +func (s *CreditGrantTestSuite) TestCreatePromotionalGrant() { + ctx := context.Background() + ns := s.GetUniqueNamespace("creditgrant-service-promotional") + + cust := s.createLedgerBackedCustomer(ns, "test-subject") + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:53Z", time.UTC).AsTime() + clock.SetTime(now) + + grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ + Namespace: ns, + CustomerID: cust.ID, + Name: "Promotional grant", + Description: lo.ToPtr("Promotional credit grant"), + Currency: USD, + Amount: alpacadecimal.NewFromInt(25), + Priority: lo.ToPtr(int16(15)), + FundingMethod: creditgrant.FundingMethodNone, + }) + s.Require().NoError(err) + + s.Equal(creditpurchase.SettlementTypePromotional, grant.Intent.Settlement.Type()) + s.Equal(creditpurchase.StatusFinal, grant.Status) + s.NotNil(grant.Realizations.CreditGrantRealization) + s.Nil(grant.Realizations.ExternalPaymentSettlement) + s.Nil(grant.Realizations.InvoiceSettlement) + + gatheringInvoices, err := s.BillingService.ListGatheringInvoices(ctx, billing.ListGatheringInvoicesInput{ + Namespaces: []string{ns}, + Customers: []string{cust.ID}, + Expand: []billing.GatheringInvoiceExpand{billing.GatheringInvoiceExpandLines}, + }) + s.Require().NoError(err) + s.Len(gatheringInvoices.Items, 0) +} + +func (s *CreditGrantTestSuite) TestCreateExternalGrantAndSettle() { + ctx := context.Background() + ns := s.GetUniqueNamespace("creditgrant-service-external") + + cust := s.createLedgerBackedCustomer(ns, "test-subject") + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:53Z", time.UTC).AsTime() + clock.SetTime(now) + + grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ + Namespace: ns, + CustomerID: cust.ID, + Name: "External grant", + Description: lo.ToPtr("External credit grant"), + Currency: USD, + Amount: alpacadecimal.NewFromInt(30), + Priority: lo.ToPtr(int16(20)), + FundingMethod: creditgrant.FundingMethodExternal, + Purchase: &creditgrant.PurchaseTerms{ + Currency: USD, + PerUnitCostBasis: lo.ToPtr(alpacadecimal.NewFromFloat(0.5)), + AvailabilityPolicy: lo.ToPtr(creditpurchase.CreatedInitialPaymentSettlementStatus), + }, + }) + s.Require().NoError(err) + + s.Equal(creditpurchase.SettlementTypeExternal, grant.Intent.Settlement.Type()) + s.Equal(creditpurchase.StatusActive, grant.Status) + s.NotNil(grant.Realizations.CreditGrantRealization) + s.Nil(grant.Realizations.ExternalPaymentSettlement) + + grant, err = s.CreditGrantService.UpdateExternalSettlement(ctx, creditgrant.UpdateExternalSettlementInput{ + Namespace: ns, + CustomerID: cust.ID, + ChargeID: grant.ID, + TargetStatus: payment.StatusAuthorized, + }) + s.Require().NoError(err) + s.Equal(creditpurchase.StatusActive, grant.Status) + s.NotNil(grant.Realizations.ExternalPaymentSettlement) + s.Equal(payment.StatusAuthorized, grant.Realizations.ExternalPaymentSettlement.Status) + + grant, err = s.CreditGrantService.UpdateExternalSettlement(ctx, creditgrant.UpdateExternalSettlementInput{ + Namespace: ns, + CustomerID: cust.ID, + ChargeID: grant.ID, + TargetStatus: payment.StatusSettled, + }) + s.Require().NoError(err) + + s.Equal(creditpurchase.StatusFinal, grant.Status) + s.NotNil(grant.Realizations.ExternalPaymentSettlement) + s.Equal(payment.StatusSettled, grant.Realizations.ExternalPaymentSettlement.Status) +} + +func (s *CreditGrantTestSuite) TestListCreditGrants() { + ctx := context.Background() + ns := s.GetUniqueNamespace("creditgrant-service-list") + + cust := s.createLedgerBackedCustomer(ns, "test-subject") + sandboxApp := s.InstallSandboxApp(s.T(), ns) + _ = s.ProvisionBillingProfile(ctx, ns, sandboxApp.GetID()) + + now := datetime.MustParseTimeInLocation(s.T(), "2026-04-17T11:23:53Z", time.UTC).AsTime() + clock.SetTime(now) + + firstGrant := s.mustCreatePromotionalCreditGrant(ctx, ns, cust.GetID(), "list-grant-1", alpacadecimal.NewFromInt(10)) + secondGrant := s.mustCreatePromotionalCreditGrant(ctx, ns, cust.GetID(), "list-grant-2", alpacadecimal.NewFromInt(20)) + + result, err := s.CreditGrantService.List(ctx, creditgrant.ListInput{ + Namespace: ns, + CustomerID: cust.ID, + }) + s.Require().NoError(err) + s.Len(result.Items, 2) + + ids := lo.Map(result.Items, func(item creditpurchase.Charge, _ int) string { + return item.ID + }) + s.Contains(ids, firstGrant.ID) + s.Contains(ids, secondGrant.ID) +} + +func (s *CreditGrantTestSuite) mustCreatePromotionalCreditGrant(ctx context.Context, namespace string, customerID customer.CustomerID, name string, amount alpacadecimal.Decimal) creditpurchase.Charge { + s.T().Helper() + + grant, err := s.CreditGrantService.Create(ctx, creditgrant.CreateInput{ + Namespace: namespace, + CustomerID: customerID.ID, + Name: name, + Description: lo.ToPtr(name), + Currency: USD, + Amount: amount, + Priority: lo.ToPtr(int16(5)), + FundingMethod: creditgrant.FundingMethodNone, + }) + s.Require().NoError(err) + + return grant +}