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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace Credits.Grants {
):
| Shared.CreateResponse<CreditGrant>
| Common.NotFound
| Common.Conflict
| Common.ErrorResponses;

/**
Expand Down
640 changes: 320 additions & 320 deletions api/v3/api.gen.go

Large diffs are not rendered by default.

32 changes: 17 additions & 15 deletions api/v3/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,8 @@ paths:
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
$ref: '#/components/responses/Conflict'
tags:
- OpenMeter Customers
requestBody:
Expand Down Expand Up @@ -11651,6 +11653,21 @@ components:
example: kong:trace:1234567890
detail:
example: Gone
ConflictError:
allOf:
- $ref: '#/components/schemas/BaseError'
- type: object
properties:
status:
example: 409
title:
example: Conflict
type:
example: https://httpstatuses.com/409
instance:
example: kong:trace:1234567890
detail:
example: Conflict
CursorMetaPage:
type: object
required:
Expand Down Expand Up @@ -11688,21 +11705,6 @@ components:
properties:
page:
$ref: '#/components/schemas/CursorMetaPage'
ConflictError:
allOf:
- $ref: '#/components/schemas/BaseError'
- type: object
properties:
status:
example: 409
title:
example: Conflict
type:
example: https://httpstatuses.com/409
instance:
example: kong:trace:1234567890
detail:
example: Conflict
responses:
BadRequest:
description: Bad Request
Expand Down
49 changes: 49 additions & 0 deletions e2e/customer_credits_v3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package e2e
import (
"net/http"
"net/url"
"strings"
"testing"
"time"

"github.com/oklog/ulid/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

apiv3 "github.com/openmeterio/openmeter/api/v3"
)

func TestV3CustomerCreditBalanceTimestampParamParsing(t *testing.T) {
Expand All @@ -35,3 +38,49 @@ func TestV3CustomerCreditBalanceTimestampParamParsing(t *testing.T) {
assert.Contains(t, problem.Detail, "timestamp")
})
}

// TestV3CreateCreditGrantMissingTaxCode verifies the documented contract for
// create-credit-grant: referencing a tax code that does not exist is rejected
// with HTTP 400 (a validation error), not a 412/500. The OpenAPI spec documents
// 400 for this operation, so this asserts the in-contract behavior end-to-end
// through the HTTP layer rather than only at the service boundary.
func TestV3CreateCreditGrantMissingTaxCode(t *testing.T) {
c := newV3Client(t)

currency := apiv3.CurrencyCode("USD")
status, customer, problem := c.CreateCustomer(apiv3.CreateCustomerRequest{
Key: uniqueKey("credit_grant_taxcode_customer"),
Name: "Credit Grant Tax Code Test Customer",
Currency: &currency,
})
require.Equal(t, http.StatusCreated, status, "problem: %+v", problem)
require.NotNil(t, customer)

// A structurally valid but non-existent tax code ULID.
missingTaxCode := ulid.Make().String()

status, _, problem = c.CreateCreditGrant(customer.Id, apiv3.CreateCreditGrantRequest{
Name: "grant with missing tax code",
Amount: apiv3.Numeric("10"),
Currency: currency,
FundingMethod: apiv3.BillingCreditFundingMethodNone,
TaxConfig: &apiv3.CreateCreditGrantTaxConfig{
TaxCode: &apiv3.CreateResourceReference{Id: missingTaxCode},
},
})

require.Equal(t, http.StatusBadRequest, status, "missing tax code must be a 400 validation error, problem: %+v", problem)
require.NotNil(t, problem)

// The offending tax code is named either in the top-level Detail or in the
// structured validationErrors[] message, depending on which error layer
// renders it. Accept either so the test asserts the contract, not the
// rendering shape.
mentionsTaxCode := strings.Contains(problem.Detail, "tax code")
for _, ve := range problem.ValidationErrors() {
if strings.Contains(ve.Message, "tax code") || strings.Contains(ve.Field, "tax_code") {
mentionsTaxCode = true
}
}
assert.True(t, mentionsTaxCode, "response should name the missing tax code, problem: %+v", problem)
}
8 changes: 8 additions & 0 deletions e2e/v3helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ func (c *v3Client) GetCustomerEntitlementAccess(customerID string) (int, *apiv3.
return decodeTyped[apiv3.ListCustomerEntitlementAccessResponseData](c, status, raw, problem, http.StatusOK)
}

// CreateCreditGrant posts a credit grant for the given customer. customerID is
// the customer's ULID. Returns the decoded grant on 201, or (status, nil,
// problem) otherwise so error-path tests can assert the status and problem body.
func (c *v3Client) CreateCreditGrant(customerID string, body apiv3.CreateCreditGrantRequest) (int, *apiv3.BillingCreditGrant, *v3Problem) {
status, raw, problem := c.do(http.MethodPost, "/customers/"+customerID+"/credits/grants", body)
return decodeTyped[apiv3.BillingCreditGrant](c, status, raw, problem, http.StatusCreated)
}

// --- Subscriptions ---

func (c *v3Client) CreateSubscription(body apiv3.BillingSubscriptionCreate) (int, *apiv3.BillingSubscription, *v3Problem) {
Expand Down
28 changes: 27 additions & 1 deletion openmeter/billing/charges/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,33 @@ func (c ChargeIntent) GetUniqueReferenceID() (*string, error) {
return nil, fmt.Errorf("invalid charge type: %s", c.t)
}

// Meta returns the shared meta.Intent embedded in every charge type.
// TaxCodeID returns the intent's configured tax code ID.
// It is empty when no tax code is set.
func (i ChargeIntent) TaxCodeID() (string, error) {
switch i.t {
case meta.ChargeTypeFlatFee:
if i.flatFee == nil {
return "", fmt.Errorf("flat fee is nil")
}

return i.flatFee.TaxConfig.TaxCodeID, nil
case meta.ChargeTypeUsageBased:
if i.usageBased == nil {
return "", fmt.Errorf("usage based is nil")
}

return i.usageBased.TaxConfig.TaxCodeID, nil
case meta.ChargeTypeCreditPurchase:
if i.creditPurchase == nil {
return "", fmt.Errorf("credit purchase is nil")
}

return i.creditPurchase.TaxConfig.TaxCodeID, nil
}

return "", fmt.Errorf("unsupported charge type: %s", i.t)
}

// WithTaxCodeID returns a copy of the intent with TaxCodeID set to id.
// Existing tax behavior and other intent fields are preserved.
func (i ChargeIntent) WithTaxCodeID(id string) (ChargeIntent, error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase"
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta"
"github.com/openmeterio/openmeter/openmeter/ent/db"
dbchargecreditpurchase "github.com/openmeterio/openmeter/openmeter/ent/db/chargecreditpurchase"
Expand Down Expand Up @@ -89,7 +90,7 @@ func (a *adapter) CreateCharge(ctx context.Context, in creditpurchase.CreateChar

dbCreditPurchase, err := create.Save(ctx)
if err != nil {
return creditpurchase.Charge{}, err
return creditpurchase.Charge{}, metaadapter.MapChargeConstraintError(err)
}

err = tx.metaAdapter.RegisterCharges(ctx, meta.RegisterChargesInput{
Expand Down
3 changes: 2 additions & 1 deletion openmeter/billing/charges/flatfee/adapter/charge.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee"
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
metaadapter "github.com/openmeterio/openmeter/openmeter/billing/charges/meta/adapter"
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/chargemeta"
"github.com/openmeterio/openmeter/openmeter/ent/db"
dbchargeflatfee "github.com/openmeterio/openmeter/openmeter/ent/db/chargeflatfee"
Expand Down Expand Up @@ -208,7 +209,7 @@ func (a *adapter) CreateCharges(ctx context.Context, in flatfee.CreateChargesInp

entities, err := tx.db.ChargeFlatFee.CreateBulk(creates...).Save(ctx)
if err != nil {
return nil, err
return nil, metaadapter.MapChargeConstraintError(err)
}

// Let's reserve the charge IDs
Expand Down
32 changes: 32 additions & 0 deletions openmeter/billing/charges/meta/adapter/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package adapter

import (
"fmt"

"entgo.io/ent/dialect/sql/sqlgraph"

entdb "github.com/openmeterio/openmeter/openmeter/ent/db"
"github.com/openmeterio/openmeter/pkg/models"
)

// MapChargeConstraintError translates an ent DB constraint violation errors.
func MapChargeConstraintError(err error) error {
if err == nil || !entdb.IsConstraintError(err) {
return err
}

switch {
case sqlgraph.IsUniqueConstraintError(err):
return models.NewGenericConflictError(
fmt.Errorf("charge conflicts with an existing charge: %w", err),
)
case sqlgraph.IsForeignKeyConstraintError(err):
return models.NewGenericValidationError(
fmt.Errorf("charge references a resource that does not exist: %w", err),
)
default:
return models.NewGenericValidationError(
fmt.Errorf("charge violates a database constraint: %w", err),
)
}
}
69 changes: 46 additions & 23 deletions openmeter/billing/charges/service/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/openmeterio/openmeter/pkg/clock"
"github.com/openmeterio/openmeter/pkg/currencyx"
"github.com/openmeterio/openmeter/pkg/framework/transaction"
"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/ref"
"github.com/openmeterio/openmeter/pkg/slicesx"
)
Expand All @@ -43,29 +44,9 @@ func (s *service) applyDefaultTaxCodes(ctx context.Context, namespace string, in
})

return slicesx.MapWithErr(intents, func(intent charges.ChargeIntent) (charges.ChargeIntent, error) {
var taxCodeID string

switch intent.Type() {
case meta.ChargeTypeFlatFee:
flatFee, err := intent.AsFlatFeeIntent()
if err != nil {
return charges.ChargeIntent{}, err
}
taxCodeID = flatFee.TaxConfig.TaxCodeID
case meta.ChargeTypeCreditPurchase:
creditPurchase, err := intent.AsCreditPurchaseIntent()
if err != nil {
return charges.ChargeIntent{}, err
}
taxCodeID = creditPurchase.TaxConfig.TaxCodeID
case meta.ChargeTypeUsageBased:
usageBased, err := intent.AsUsageBasedIntent()
if err != nil {
return charges.ChargeIntent{}, err
}
taxCodeID = usageBased.TaxConfig.TaxCodeID
default:
return charges.ChargeIntent{}, fmt.Errorf("unsupported charge type: %s", intent.Type())
taxCodeID, err := intent.TaxCodeID()
if err != nil {
return charges.ChargeIntent{}, err
}

if taxCodeID != "" {
Expand All @@ -87,6 +68,44 @@ func (s *service) applyDefaultTaxCodes(ctx context.Context, namespace string, in
})
}

// validateTaxCodesExist verifies every distinct non-empty tax code referenced by the intents
// exists.
func (s *service) validateTaxCodesExist(ctx context.Context, namespace string, intents charges.ChargeIntents) error {
seen := make(map[string]struct{}, len(intents))

for _, intent := range intents {
taxCodeID, err := intent.TaxCodeID()
if err != nil {
return err
}

if taxCodeID == "" {
continue
}

if _, ok := seen[taxCodeID]; ok {
continue
}
seen[taxCodeID] = struct{}{}

_, err = s.taxCodeService.GetTaxCode(ctx, taxcode.GetTaxCodeInput{
NamespacedID: models.NamespacedID{Namespace: namespace, ID: taxCodeID},
})
if err != nil {
if taxcode.IsTaxCodeNotFoundError(err) {
return models.NewGenericValidationError(
models.NewValidationError("tax_code_not_found", fmt.Sprintf("referenced tax code %q does not exist", taxCodeID)).
WithPathString("tax_config", "tax_code"),
)
}

return err
}
}

return nil
}

func (s *service) Create(ctx context.Context, input charges.CreateInput) (charges.Charges, error) {
result, err := s.create(ctx, input)
if err != nil {
Expand Down Expand Up @@ -127,6 +146,10 @@ func (s *service) create(ctx context.Context, input charges.CreateInput) (*charg
return nil, err
}

if err := s.validateTaxCodesExist(ctx, input.Namespace, input.Intents); err != nil {
return nil, err
}

result, err := transaction.Run(ctx, s.adapter, func(ctx context.Context) (*chargesWithInvoiceNowActions, error) {
intentsByType, err := input.Intents.ByType()
if err != nil {
Expand Down
Loading
Loading