From 32d33828f1ae7e23a6b6a8e5e0de7f71007838ba Mon Sep 17 00:00:00 2001 From: Alex Goth <64845621+GAlexIHU@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:50:30 +0200 Subject: [PATCH 1/3] feat(credits): filter balances and transactions by plan --- .../aip-client-javascript/src/index.ts | 3 +- .../src/models/schemas.ts | 204 ++-- .../aip-client-javascript/src/models/types.ts | 89 +- .../aip/src/customers/credits/operations.tsp | 39 +- api/v3/api.gen.go | 987 +++++++++--------- api/v3/client/customers_credits_balance.go | 15 +- .../client/customers_credits_transactions.go | 16 +- .../customers/credits/feature_filter.go | 6 +- .../handlers/customers/credits/get_balance.go | 10 + .../customers/credits/list_transactions.go | 7 + .../handlers/customers/credits/plan_filter.go | 76 ++ .../customers/credits/plan_filter_test.go | 95 ++ api/v3/openapi.yaml | 80 +- openmeter/ledger/README.md | 17 +- .../ledger/account/adapter/subaccount.go | 3 + openmeter/ledger/breakage/adapter/query.go | 2 +- openmeter/ledger/breakage/breakage_impacts.go | 6 + openmeter/ledger/creditfilterquery/filters.go | 24 + openmeter/ledger/creditfilters.go | 16 + openmeter/ledger/creditvoid/adapter/query.go | 2 +- openmeter/ledger/customerbalance/README.md | 11 + .../ledger/customerbalance/expired_loader.go | 2 +- openmeter/ledger/customerbalance/facade.go | 13 + .../{feature_filter.go => filters.go} | 26 +- .../ledger/customerbalance/funded_loader.go | 5 +- .../ledger/customerbalance/ledger_loader.go | 2 +- openmeter/ledger/customerbalance/loaders.go | 1 + .../customerbalance/plan_filter_test.go | 354 +++++++ openmeter/ledger/customerbalance/service.go | 95 +- .../ledger/customerbalance/service_test.go | 6 +- .../ledger/customerbalance/testenv_test.go | 8 +- .../customerbalance/transaction_balances.go | 5 + .../ledger/customerbalance/transactions.go | 7 + .../ledger/customerbalance/voided_loader.go | 2 +- openmeter/ledger/historical/adapter/ledger.go | 6 + .../historical/adapter/sumentries_query.go | 3 + .../internal/routequery/subaccount_ids.go | 3 + openmeter/ledger/primitives.go | 11 +- openmeter/ledger/routing.go | 19 +- 39 files changed, 1572 insertions(+), 704 deletions(-) create mode 100644 api/v3/handlers/customers/credits/plan_filter.go create mode 100644 api/v3/handlers/customers/credits/plan_filter_test.go rename openmeter/ledger/customerbalance/{feature_filter.go => filters.go} (60%) create mode 100644 openmeter/ledger/customerbalance/plan_filter_test.go diff --git a/api/spec/packages/aip-client-javascript/src/index.ts b/api/spec/packages/aip-client-javascript/src/index.ts index b1b1292acb..7538909fdd 100644 --- a/api/spec/packages/aip-client-javascript/src/index.ts +++ b/api/spec/packages/aip-client-javascript/src/index.ts @@ -226,11 +226,11 @@ export type { RateCardMeteredEntitlement, SubscriptionPhaseCreate, SubscriptionEditStretchPhase, - GetCreditBalanceParamsFilter, ListPlansParamsFilter, CreateCreditGrantPlanFilter, CreditGrantPlanFilter, ValidationIssue, + GetCreditBalanceParamsFilter, SubscriptionProRatingConfig, RateCardProrationConfiguration, UnitConfig, @@ -461,6 +461,7 @@ export type { MeterQueryGranularity, EntitlementHistoryWindowSize, StringFieldFilterExact, + NumericFieldFilter, PricePaymentTerm, BooleanFieldFilter, BillingCurrencyCode, diff --git a/api/spec/packages/aip-client-javascript/src/models/schemas.ts b/api/spec/packages/aip-client-javascript/src/models/schemas.ts index 3653c7d367..8cd5474f18 100644 --- a/api/spec/packages/aip-client-javascript/src/models/schemas.ts +++ b/api/spec/packages/aip-client-javascript/src/models/schemas.ts @@ -675,6 +675,48 @@ export const validationIssueSeverity = z .enum(['critical', 'warning']) .describe('Severity level of a billing validation issue.') +export const numericFieldFilter = z + .union([ + z.number(), + z.object({ + eq: z + .number() + .optional() + .describe('Value strictly equals the given numeric value.'), + neq: z + .number() + .optional() + .describe('Value does not equal the given numeric value.'), + oeq: z + .array(z.number()) + .optional() + + .describe( + 'Returns entities that match any of the comma-delimited numeric values.', + ), + lt: z + .number() + .optional() + .describe('Value is less than the given numeric value.'), + lte: z + .number() + .optional() + .describe('Value is less than or equal to the given numeric value.'), + gt: z + .number() + .optional() + .describe('Value is greater than the given numeric value.'), + gte: z + .number() + .optional() + .describe('Value is greater than or equal to the given numeric value.'), + }), + ]) + + .describe( + 'Filter by a numeric value. All properties are optional; provide exactly one to specify the comparison.', + ) + export const creditGrantVoidPaymentAdjustment = z .enum(['none']) @@ -1542,48 +1584,6 @@ export const publicLabels = z 'Public labels store information about an entity that can be used for filtering a list of objects.', ) -export const numericFieldFilter = z - .union([ - z.number(), - z.object({ - eq: z - .number() - .optional() - .describe('Value strictly equals the given numeric value.'), - neq: z - .number() - .optional() - .describe('Value does not equal the given numeric value.'), - oeq: z - .array(z.number()) - .optional() - - .describe( - 'Returns entities that match any of the comma-delimited numeric values.', - ), - lt: z - .number() - .optional() - .describe('Value is less than the given numeric value.'), - lte: z - .number() - .optional() - .describe('Value is less than or equal to the given numeric value.'), - gt: z - .number() - .optional() - .describe('Value is greater than the given numeric value.'), - gte: z - .number() - .optional() - .describe('Value is greater than or equal to the given numeric value.'), - }), - ]) - - .describe( - 'Filter by a numeric value. All properties are optional; provide exactly one to specify the comparison.', - ) - export const chargeCostBasisType = z .enum(['dynamic', 'pinned', 'manual']) .describe('Cost basis resolution mode of a charge.') @@ -3354,13 +3354,6 @@ export const entitlementMeasureUsageFrom = z 'The time from which usage is measured, as a preset or an explicit timestamp.', ) -export const getCreditBalanceParamsFilter = z - .object({ - currency: stringFieldFilterExact.optional(), - featureKey: stringFieldFilter.optional(), - }) - .describe('Filter options for getting a credit balance.') - export const listPlansParamsFilter = z .object({ key: stringFieldFilter.optional(), @@ -3422,6 +3415,15 @@ export const validationIssue = z }) .describe('A validation issue found while processing a billing resource.') +export const getCreditBalanceParamsFilter = z + .object({ + currency: stringFieldFilterExact.optional(), + featureKey: stringFieldFilter.optional(), + planKey: stringFieldFilter.optional(), + planVersion: numericFieldFilter.optional(), + }) + .describe('Filter options for getting a credit balance.') + export const voidCreditGrantRequest = z .object({ paymentAdjustment: creditGrantVoidPaymentAdjustment @@ -4021,6 +4023,8 @@ export const listCreditTransactionsParamsFilter = z type: creditTransactionType.optional(), currency: billingCurrencyCode.optional(), featureKey: stringFieldFilter.optional(), + planKey: stringFieldFilter.optional(), + planVersion: numericFieldFilter.optional(), }) .describe('Filter options for listing credit transactions.') @@ -10507,6 +10511,48 @@ export const validationIssueSeverityWire = z .enum(['critical', 'warning']) .describe('Severity level of a billing validation issue.') +export const numericFieldFilterWire = z + .union([ + z.number(), + z.strictObject({ + eq: z + .number() + .optional() + .describe('Value strictly equals the given numeric value.'), + neq: z + .number() + .optional() + .describe('Value does not equal the given numeric value.'), + oeq: z + .array(z.number()) + .optional() + + .describe( + 'Returns entities that match any of the comma-delimited numeric values.', + ), + lt: z + .number() + .optional() + .describe('Value is less than the given numeric value.'), + lte: z + .number() + .optional() + .describe('Value is less than or equal to the given numeric value.'), + gt: z + .number() + .optional() + .describe('Value is greater than the given numeric value.'), + gte: z + .number() + .optional() + .describe('Value is greater than or equal to the given numeric value.'), + }), + ]) + + .describe( + 'Filter by a numeric value. All properties are optional; provide exactly one to specify the comparison.', + ) + export const creditGrantVoidPaymentAdjustmentWire = z .enum(['none']) @@ -11368,48 +11414,6 @@ export const publicLabelsWire = z 'Public labels store information about an entity that can be used for filtering a list of objects.', ) -export const numericFieldFilterWire = z - .union([ - z.number(), - z.strictObject({ - eq: z - .number() - .optional() - .describe('Value strictly equals the given numeric value.'), - neq: z - .number() - .optional() - .describe('Value does not equal the given numeric value.'), - oeq: z - .array(z.number()) - .optional() - - .describe( - 'Returns entities that match any of the comma-delimited numeric values.', - ), - lt: z - .number() - .optional() - .describe('Value is less than the given numeric value.'), - lte: z - .number() - .optional() - .describe('Value is less than or equal to the given numeric value.'), - gt: z - .number() - .optional() - .describe('Value is greater than the given numeric value.'), - gte: z - .number() - .optional() - .describe('Value is greater than or equal to the given numeric value.'), - }), - ]) - - .describe( - 'Filter by a numeric value. All properties are optional; provide exactly one to specify the comparison.', - ) - export const chargeCostBasisTypeWire = z .enum(['dynamic', 'pinned', 'manual']) .describe('Cost basis resolution mode of a charge.') @@ -13172,13 +13176,6 @@ export const entitlementMeasureUsageFromWire = z 'The time from which usage is measured, as a preset or an explicit timestamp.', ) -export const getCreditBalanceParamsFilterWire = z - .strictObject({ - currency: stringFieldFilterExactWire.optional(), - feature_key: stringFieldFilterWire.optional(), - }) - .describe('Filter options for getting a credit balance.') - export const listPlansParamsFilterWire = z .strictObject({ key: stringFieldFilterWire.optional(), @@ -13240,6 +13237,15 @@ export const validationIssueWire = z }) .describe('A validation issue found while processing a billing resource.') +export const getCreditBalanceParamsFilterWire = z + .strictObject({ + currency: stringFieldFilterExactWire.optional(), + feature_key: stringFieldFilterWire.optional(), + plan_key: stringFieldFilterWire.optional(), + plan_version: numericFieldFilterWire.optional(), + }) + .describe('Filter options for getting a credit balance.') + export const voidCreditGrantRequestWire = z .strictObject({ payment_adjustment: creditGrantVoidPaymentAdjustmentWire.optional(), @@ -13830,6 +13836,8 @@ export const listCreditTransactionsParamsFilterWire = z type: creditTransactionTypeWire.optional(), currency: billingCurrencyCodeWire.optional(), feature_key: stringFieldFilterWire.optional(), + plan_key: stringFieldFilterWire.optional(), + plan_version: numericFieldFilterWire.optional(), }) .describe('Filter options for listing credit transactions.') diff --git a/api/spec/packages/aip-client-javascript/src/models/types.ts b/api/spec/packages/aip-client-javascript/src/models/types.ts index 198f35fefa..060e5ea64f 100644 --- a/api/spec/packages/aip-client-javascript/src/models/types.ts +++ b/api/spec/packages/aip-client-javascript/src/models/types.ts @@ -2144,20 +2144,6 @@ export interface SubscriptionEditStretchPhase { extendBy: string } -/** Filter options for getting a credit balance. */ -export interface GetCreditBalanceParamsFilter { - /** - * Filter credit balance by currency code. When historical custom currencies reuse - * a code, each managed currency is returned as a separate balance row. - */ - currency?: StringFieldFilterExact - /** - * Filter credit balance by feature key. Omit to return the total portfolio value. - * Use `exists=false` to return only unrestricted balance. - */ - featureKey?: StringFieldFilter -} - /** Filter options for listing plans. */ export interface ListPlansParamsFilter { key?: StringFieldFilter @@ -2207,6 +2193,35 @@ export interface ValidationIssue { component?: string } +/** Filter options for getting a credit balance. */ +export interface GetCreditBalanceParamsFilter { + /** + * Filter credit balance by currency code. When historical custom currencies reuse + * a code, each managed currency is returned as a separate balance row. + */ + currency?: StringFieldFilterExact + /** + * Filter credit balance by feature key. Omit to return the total portfolio value. + * Use `exists=false` to return only credits without feature restrictions; those + * credits may still have plan restrictions. + */ + featureKey?: StringFieldFilter + /** + * Select credits matching one plan key, including credits without plan + * restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + * single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + * without plan restrictions. Feature and plan selections combine with AND. + */ + planKey?: StringFieldFilter + /** + * Select one positive integer plan version using `eq` (or shorthand). Requires a + * concrete `plan_key`. Credits whose version constraints match this version, and + * credits without plan restrictions, are included. Omit to include every version + * of the selected plan. + */ + planVersion?: NumericFieldFilter +} + /** Request body for voiding a credit grant. */ export interface VoidCreditGrantRequest { /** @@ -2820,10 +2835,24 @@ export interface ListCreditTransactionsParamsFilter { currency?: BillingCurrencyCode /** * Filter credit transactions by feature key. Omit to return all credit - * transactions. Use `exists=false` to return only unrestricted credit - * transactions. + * transactions. Use `exists=false` to return only movements on routes without + * feature restrictions; those routes may still have plan restrictions. */ featureKey?: StringFieldFilter + /** + * Select credits matching one plan key, including credits without plan + * restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + * single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + * without plan restrictions. Feature and plan selections combine with AND. + */ + planKey?: StringFieldFilter + /** + * Select one positive integer plan version using `eq` (or shorthand). Requires a + * concrete `plan_key`. Credits whose version constraints match this version, and + * credits without plan restrictions, are included. Omit to include every version + * of the selected plan. + */ + planVersion?: NumericFieldFilter } /** Monetary amount in a fiat or custom currency. */ @@ -3786,17 +3815,7 @@ export interface ResourceFilters { /** Field filters with all supported types. */ export interface FieldFilters { boolean?: BooleanFieldFilter - numeric?: - | number - | { - eq?: number - neq?: number - oeq?: number[] - lt?: number - lte?: number - gt?: number - gte?: number - } + numeric?: NumericFieldFilter string?: StringFieldFilter stringExact?: StringFieldFilterExact ulid?: UlidFieldFilter @@ -8051,6 +8070,22 @@ export type EntitlementHistoryWindowSize = 'PT1H' | 'P1D' export type StringFieldFilterExact = string | { eq?: string; oeq?: string[]; neq?: string } +/** + * Filter by a numeric value. All properties are optional; provide exactly one to + * specify the comparison. + */ +export type NumericFieldFilter = + | number + | { + eq?: number + neq?: number + oeq?: number[] + lt?: number + lte?: number + gt?: number + gte?: number + } + /** The payment term of a flat price. */ export type PricePaymentTerm = 'in_advance' | 'in_arrears' diff --git a/api/spec/packages/aip/src/customers/credits/operations.tsp b/api/spec/packages/aip/src/customers/credits/operations.tsp index 1154f208e8..17c3c747f9 100644 --- a/api/spec/packages/aip/src/customers/credits/operations.tsp +++ b/api/spec/packages/aip/src/customers/credits/operations.tsp @@ -191,9 +191,26 @@ model GetCreditBalanceParamsFilter { /** * Filter credit balance by feature key. Omit to return the total portfolio value. - * Use `exists=false` to return only unrestricted balance. + * Use `exists=false` to return only credits without feature restrictions; those + * credits may still have plan restrictions. */ feature_key?: Common.StringFieldFilter; + + /** + * Select credits matching one plan key, including credits without plan + * restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + * single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + * without plan restrictions. Feature and plan selections combine with AND. + */ + plan_key?: Common.StringFieldFilter; + + /** + * Select one positive integer plan version using `eq` (or shorthand). Requires a + * concrete `plan_key`. Credits whose version constraints match this version, and + * credits without plan restrictions, are included. Omit to include every version + * of the selected plan. + */ + plan_version?: Common.NumericFieldFilter; } namespace Credits.Balance { @@ -246,11 +263,27 @@ model ListCreditTransactionsParamsFilter { /** * Filter credit transactions by feature key. Omit to return all credit - * transactions. Use `exists=false` to return only unrestricted credit - * transactions. + * transactions. Use `exists=false` to return only movements on routes without + * feature restrictions; those routes may still have plan restrictions. */ feature_key?: Common.StringFieldFilter; + /** + * Select credits matching one plan key, including credits without plan + * restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + * single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + * without plan restrictions. Feature and plan selections combine with AND. + */ + plan_key?: Common.StringFieldFilter; + + /** + * Select one positive integer plan version using `eq` (or shorthand). Requires a + * concrete `plan_key`. Credits whose version constraints match this version, and + * credits without plan restrictions, are included. Omit to include every version + * of the selected plan. + */ + plan_version?: Common.NumericFieldFilter; + // TODO: add date filters for created_at and booked_at } diff --git a/api/v3/api.gen.go b/api/v3/api.gen.go index a30c946f3c..a0cc7b61fa 100644 --- a/api/v3/api.gen.go +++ b/api/v3/api.gen.go @@ -9499,8 +9499,21 @@ type GetCreditBalanceParamsFilter struct { Currency *StringFieldFilterExact `json:"currency,omitempty"` // FeatureKey Filter credit balance by feature key. Omit to return the total portfolio value. - // Use `exists=false` to return only unrestricted balance. + // Use `exists=false` to return only credits without feature restrictions; those + // credits may still have plan restrictions. FeatureKey *StringFieldFilter `json:"feature_key,omitempty"` + + // PlanKey Select credits matching one plan key, including credits without plan + // restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + // single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + // without plan restrictions. Feature and plan selections combine with AND. + PlanKey *StringFieldFilter `json:"plan_key,omitempty"` + + // PlanVersion Select one positive integer plan version using `eq` (or shorthand). Requires a + // concrete `plan_key`. Credits whose version constraints match this version, and + // credits without plan restrictions, are included. Omit to include every version + // of the selected plan. + PlanVersion *NumericFieldFilter `json:"plan_version,omitempty"` } // GoneError defines model for GoneError. @@ -9827,10 +9840,22 @@ type ListCreditTransactionsParamsFilter struct { Currency *BillingCurrencyCode `json:"currency,omitempty"` // FeatureKey Filter credit transactions by feature key. Omit to return all credit - // transactions. Use `exists=false` to return only unrestricted credit - // transactions. + // transactions. Use `exists=false` to return only movements on routes without + // feature restrictions; those routes may still have plan restrictions. FeatureKey *StringFieldFilter `json:"feature_key,omitempty"` + // PlanKey Select credits matching one plan key, including credits without plan + // restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + // single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + // without plan restrictions. Feature and plan selections combine with AND. + PlanKey *StringFieldFilter `json:"plan_key,omitempty"` + + // PlanVersion Select one positive integer plan version using `eq` (or shorthand). Requires a + // concrete `plan_key`. Credits whose version constraints match this version, and + // credits without plan restrictions, are included. Omit to include every version + // of the selected plan. + PlanVersion *NumericFieldFilter `json:"plan_version,omitempty"` + // Type Filter credit transactions by type. Type *BillingCreditTransactionType `json:"type,omitempty"` } @@ -10436,6 +10461,10 @@ type NotificationRulePagePaginatedResponse struct { // Numeric Numeric represents an arbitrary precision number. type Numeric = string +// NumericFieldFilter Filter by a numeric value. All properties are optional; provide exactly one to +// specify the comparison. +type NumericFieldFilter = filters.FilterNumeric + // OrganizationDefaultTaxCodes Organization-level default tax code references. // // Stores the default tax codes applied to specific billing contexts for this @@ -23639,478 +23668,486 @@ var swaggerSpec = []string{ "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", + "AA==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/api/v3/client/customers_credits_balance.go b/api/v3/client/customers_credits_balance.go index e2b8c02705..a00942abc5 100644 --- a/api/v3/client/customers_credits_balance.go +++ b/api/v3/client/customers_credits_balance.go @@ -19,8 +19,19 @@ type GetCustomerCreditBalanceFilter struct { // a code, each managed currency is returned as a separate balance row. Currency *StringExactFilter // Filter credit balance by feature key. Omit to return the total portfolio value. - // Use `exists=false` to return only unrestricted balance. + // Use `exists=false` to return only credits without feature restrictions; those + // credits may still have plan restrictions. FeatureKey *StringFilter + // Select credits matching one plan key, including credits without plan + // restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + // single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + // without plan restrictions. Feature and plan selections combine with AND. + PlanKey *StringFilter + // Select one positive integer plan version using `eq` (or shorthand). Requires a + // concrete `plan_key`. Credits whose version constraints match this version, and + // credits without plan restrictions, are included. Omit to include every version + // of the selected plan. + PlanVersion *NumericFilter } type GetCustomerCreditBalanceParams struct { @@ -38,6 +49,8 @@ func (p GetCustomerCreditBalanceParams) values() url.Values { if p.Filter != nil { addStringExactFilter(q, "filter[currency]", p.Filter.Currency) addStringFilter(q, "filter[feature_key]", p.Filter.FeatureKey) + addStringFilter(q, "filter[plan_key]", p.Filter.PlanKey) + addNumericFilter(q, "filter[plan_version]", p.Filter.PlanVersion) } return q diff --git a/api/v3/client/customers_credits_transactions.go b/api/v3/client/customers_credits_transactions.go index c2ce49701e..e42241adda 100644 --- a/api/v3/client/customers_credits_transactions.go +++ b/api/v3/client/customers_credits_transactions.go @@ -21,9 +21,19 @@ type CreditTransactionFilter struct { // managed currency identifier. Currency *BillingCurrencyCode // Filter credit transactions by feature key. Omit to return all credit - // transactions. Use `exists=false` to return only unrestricted credit - // transactions. + // transactions. Use `exists=false` to return only movements on routes without + // feature restrictions; those routes may still have plan restrictions. FeatureKey *StringFilter + // Select credits matching one plan key, including credits without plan + // restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + // single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + // without plan restrictions. Feature and plan selections combine with AND. + PlanKey *StringFilter + // Select one positive integer plan version using `eq` (or shorthand). Requires a + // concrete `plan_key`. Credits whose version constraints match this version, and + // credits without plan restrictions, are included. Omit to include every version + // of the selected plan. + PlanVersion *NumericFilter } type CreditTransactionListParams struct { @@ -44,6 +54,8 @@ func (p CreditTransactionListParams) values() url.Values { q.Set("filter[currency]", string(*p.Filter.Currency)) } addStringFilter(q, "filter[feature_key]", p.Filter.FeatureKey) + addStringFilter(q, "filter[plan_key]", p.Filter.PlanKey) + addNumericFilter(q, "filter[plan_version]", p.Filter.PlanVersion) } return q diff --git a/api/v3/handlers/customers/credits/feature_filter.go b/api/v3/handlers/customers/credits/feature_filter.go index 69bd758734..167efeb903 100644 --- a/api/v3/handlers/customers/credits/feature_filter.go +++ b/api/v3/handlers/customers/credits/feature_filter.go @@ -26,7 +26,7 @@ func fromAPICustomerCreditFeatureFilter(f *api.StringFieldFilter) (mo.Option[cre return customerbalance.AllFeatureFilter(), errors.New("exists=true operator is not supported") } - if op := unsupportedCustomerCreditFeatureKeyOperator(f); op != "" { + if op := unsupportedCustomerCreditKeyOperator(f); op != "" { return customerbalance.AllFeatureFilter(), fmt.Errorf("%s operator is not supported", op) } @@ -49,7 +49,7 @@ func fromAPICustomerCreditFeatureFilter(f *api.StringFieldFilter) (mo.Option[cre return featureFilter, nil } -func unsupportedCustomerCreditFeatureKeyOperator(f *api.StringFieldFilter) string { +func unsupportedCustomerCreditKeyOperator(f *api.StringFieldFilter) string { switch { case f.Neq != nil: return "neq" @@ -89,7 +89,7 @@ func fromAPICreditGrantFeatureFilters(f *api.StringFieldFilter) (mo.Option[credi return customerbalance.AllFeatureFilter(), nil } - if op := unsupportedCustomerCreditFeatureKeyOperator(f); op != "" { + if op := unsupportedCustomerCreditKeyOperator(f); op != "" { return customerbalance.AllFeatureFilter(), fmt.Errorf("%s operator is not supported", op) } diff --git a/api/v3/handlers/customers/credits/get_balance.go b/api/v3/handlers/customers/credits/get_balance.go index f841bcae33..9f7d9d59c5 100644 --- a/api/v3/handlers/customers/credits/get_balance.go +++ b/api/v3/handlers/customers/credits/get_balance.go @@ -12,6 +12,7 @@ import ( "github.com/openmeterio/openmeter/api/v3/apierrors" "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" "github.com/openmeterio/openmeter/openmeter/customer" + "github.com/openmeterio/openmeter/openmeter/ledger" "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" "github.com/openmeterio/openmeter/pkg/clock" "github.com/openmeterio/openmeter/pkg/currencyx" @@ -24,6 +25,7 @@ type ( CustomerID customer.CustomerID Currencies customerbalance.CurrencyFilter FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] RetrievedAt time.Time HistoricalAsOf *time.Time } @@ -92,6 +94,13 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler { } request.FeatureFilter = featureFilter + + planFilter, err := fromAPICustomerCreditPlanFilter(ctx, args.Params.Filter.PlanKey, args.Params.Filter.PlanVersion) + if err != nil { + return GetCustomerCreditBalanceRequest{}, err + } + + request.PlanFilter = planFilter } return request, nil @@ -108,6 +117,7 @@ func (h *handler) GetCustomerCreditBalance() GetCustomerCreditBalanceHandler { CustomerID: request.CustomerID, Currencies: request.Currencies, FeatureFilter: request.FeatureFilter, + PlanFilter: request.PlanFilter, AsOf: request.HistoricalAsOf, }) if err != nil { diff --git a/api/v3/handlers/customers/credits/list_transactions.go b/api/v3/handlers/customers/credits/list_transactions.go index 5b365b91d2..d53375e870 100644 --- a/api/v3/handlers/customers/credits/list_transactions.go +++ b/api/v3/handlers/customers/credits/list_transactions.go @@ -106,6 +106,13 @@ func (h *handler) ListCreditTransactions() ListCreditTransactionsHandler { } req.FeatureFilter = featureFilter + + planFilter, err := fromAPICustomerCreditPlanFilter(ctx, args.Params.Filter.PlanKey, args.Params.Filter.PlanVersion) + if err != nil { + return ListCreditTransactionsRequest{}, err + } + + req.PlanFilter = planFilter } return req, nil diff --git a/api/v3/handlers/customers/credits/plan_filter.go b/api/v3/handlers/customers/credits/plan_filter.go new file mode 100644 index 0000000000..3329c3c672 --- /dev/null +++ b/api/v3/handlers/customers/credits/plan_filter.go @@ -0,0 +1,76 @@ +package customerscredits + +import ( + "context" + "errors" + "fmt" + "math" + "slices" + + "github.com/samber/lo" + "github.com/samber/mo" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/ledger/customerbalance" +) + +func fromAPICustomerCreditPlanFilter(ctx context.Context, key *api.StringFieldFilter, version *api.NumericFieldFilter) (mo.Option[*ledger.PlanFilter], error) { + var selected mo.Option[*ledger.PlanFilter] + + if key != nil { + if op := unsupportedCustomerCreditKeyOperator(key); op != "" { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", fmt.Errorf("plan_key: %s operator is not supported", op)) + } + + if key.Exists != nil { + if *key.Exists || key.Eq != nil || len(key.Oeq) > 0 { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", errors.New("plan_key: only exists=false without other operators is supported")) + } + selected = mo.Some[*ledger.PlanFilter](nil) + } else { + keys := slices.Clone(key.Oeq) + if key.Eq != nil { + keys = append(keys, *key.Eq) + } + keys = lo.Uniq(keys) + if len(keys) > 1 { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", errors.New("plan_key: exactly one plan key is supported")) + } + if len(keys) == 1 { + selected = mo.Some(&ledger.PlanFilter{Key: keys[0]}) + } + } + } + + if version != nil { + if version.Neq != nil || len(version.Oeq) > 0 || version.Gt != nil || version.Gte != nil || version.Lt != nil || version.Lte != nil || version.Eq == nil { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_version]", errors.New("plan_version: only eq is supported")) + } + + value := *version.Eq + if math.IsNaN(value) || value < 1 || value > math.MaxInt32 || math.Trunc(value) != value { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_version]", errors.New("plan_version: must be a positive 32-bit integer")) + } + + plan := selected.OrEmpty() + if plan == nil || plan.Key == "" { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_version]", errors.New("plan_version: requires a concrete plan_key")) + } + + plan.Version = &ledger.VersionFilter{Eq: lo.ToPtr(int(value))} + } + + if err := customerbalance.ValidatePlanFilter(selected); err != nil { + return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", err) + } + + return selected, nil +} + +func newPlanFilterBadRequest(ctx context.Context, field string, err error) error { + return apierrors.NewBadRequestError(ctx, errors.New("invalid plan filter"), apierrors.InvalidParameters{{ + Field: field, Reason: err.Error(), Source: apierrors.InvalidParamSourceQuery, + }}) +} diff --git a/api/v3/handlers/customers/credits/plan_filter_test.go b/api/v3/handlers/customers/credits/plan_filter_test.go new file mode 100644 index 0000000000..43fc3facf9 --- /dev/null +++ b/api/v3/handlers/customers/credits/plan_filter_test.go @@ -0,0 +1,95 @@ +package customerscredits + +import ( + "github.com/openmeterio/openmeter/api/v3/apierrors" + "net/http" + "net/url" + "testing" + + "github.com/samber/lo" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/filters" + "github.com/openmeterio/openmeter/openmeter/ledger" +) + +func TestCustomerCreditPlanFilterQuery(t *testing.T) { + for _, tc := range []struct { + query string + want mo.Option[*ledger.PlanFilter] + invalid bool + }{ + {query: ""}, + {query: "filter[plan_key]=pro", want: mo.Some(&ledger.PlanFilter{Key: "pro"})}, + {query: "filter[plan_key][oeq]=pro", want: mo.Some(&ledger.PlanFilter{Key: "pro"})}, + {query: "filter[plan_key]=pro&filter[plan_version]=2", want: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}})}, + {query: "filter[plan_key][exists]=false", want: mo.Some[*ledger.PlanFilter](nil)}, + {query: "filter[plan_key][exists]=true", invalid: true}, + {query: "filter[plan_key][neq]=pro", invalid: true}, + {query: "filter[plan_key][contains]=pro", invalid: true}, + {query: "filter[plan_key][oeq]=pro,enterprise", invalid: true}, + {query: "filter[plan_key][eq]=", invalid: true}, + {query: "filter[plan_key]=pro&filter[plan_version][gte]=2", invalid: true}, + {query: "filter[plan_key]=pro&filter[plan_version]=0", invalid: true}, + {query: "filter[plan_key]=pro&filter[plan_version]=-1", invalid: true}, + {query: "filter[plan_key]=pro&filter[plan_version]=1.5", invalid: true}, + {query: "filter[plan_key]=pro&filter[plan_version]=2147483648", invalid: true}, + {query: "filter[plan_version]=2", invalid: true}, + {query: "filter[plan_key][exists]=false&filter[plan_version]=2", invalid: true}, + } { + t.Run(tc.query, func(t *testing.T) { + values, err := url.ParseQuery(tc.query) + require.NoError(t, err) + for _, parse := range []func() (mo.Option[*ledger.PlanFilter], error){ + func() (mo.Option[*ledger.PlanFilter], error) { + var params api.GetCreditBalanceParamsFilter + if err := filters.Parse(values, ¶ms); err != nil { + return mo.None[*ledger.PlanFilter](), err + } + return fromAPICustomerCreditPlanFilter(t.Context(), params.PlanKey, params.PlanVersion) + }, + func() (mo.Option[*ledger.PlanFilter], error) { + var params api.ListCreditTransactionsParamsFilter + if err := filters.Parse(values, ¶ms); err != nil { + return mo.None[*ledger.PlanFilter](), err + } + return fromAPICustomerCreditPlanFilter(t.Context(), params.PlanKey, params.PlanVersion) + }, + } { + got, err := parse() + if tc.invalid { + require.Error(t, err) + continue + } + require.NoError(t, err) + require.Equal(t, tc.want, got) + } + }) + } +} + +func TestCustomerCreditPlanFilterErrorField(t *testing.T) { + for _, tc := range []struct { + name string + key *api.StringFieldFilter + version *api.NumericFieldFilter + field string + }{ + {name: "key", key: &api.StringFieldFilter{Contains: lo.ToPtr("pro")}, field: "filter[plan_key]"}, + {name: "empty key", key: &api.StringFieldFilter{Eq: lo.ToPtr("")}, field: "filter[plan_key]"}, + {name: "version", key: &api.StringFieldFilter{Eq: lo.ToPtr("pro")}, version: &api.NumericFieldFilter{Eq: lo.ToPtr(0.0)}, field: "filter[plan_version]"}, + {name: "version without key", version: &api.NumericFieldFilter{Eq: lo.ToPtr(2.0)}, field: "filter[plan_version]"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := fromAPICustomerCreditPlanFilter(t.Context(), tc.key, tc.version) + var apiErr *apierrors.BaseAPIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.Status) + require.Len(t, apiErr.InvalidParameters, 1) + require.Equal(t, tc.field, apiErr.InvalidParameters[0].Field) + require.Equal(t, apierrors.InvalidParamSourceQuery, apiErr.InvalidParameters[0].Source) + }) + } +} diff --git a/api/v3/openapi.yaml b/api/v3/openapi.yaml index f424d764b5..fd7d9d7766 100644 --- a/api/v3/openapi.yaml +++ b/api/v3/openapi.yaml @@ -17017,7 +17017,24 @@ components: - $ref: '#/components/schemas/StringFieldFilter' description: |- Filter credit balance by feature key. Omit to return the total portfolio value. - Use `exists=false` to return only unrestricted balance. + Use `exists=false` to return only credits without feature restrictions; those + credits may still have plan restrictions. + plan_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Select credits matching one plan key, including credits without plan + restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + without plan restrictions. Feature and plan selections combine with AND. + plan_version: + allOf: + - $ref: '#/components/schemas/NumericFieldFilter' + description: |- + Select one positive integer plan version using `eq` (or shorthand). Requires a + concrete `plan_key`. Credits whose version constraints match this version, and + credits without plan restrictions, are included. Omit to include every version + of the selected plan. additionalProperties: false description: Filter options for getting a credit balance. ISO8601Duration: @@ -17353,8 +17370,24 @@ components: - $ref: '#/components/schemas/StringFieldFilter' description: |- Filter credit transactions by feature key. Omit to return all credit - transactions. Use `exists=false` to return only unrestricted credit - transactions. + transactions. Use `exists=false` to return only movements on routes without + feature restrictions; those routes may still have plan restrictions. + plan_key: + allOf: + - $ref: '#/components/schemas/StringFieldFilter' + description: |- + Select credits matching one plan key, including credits without plan + restrictions. Keys are case-sensitive. Supports `eq` (or shorthand) and a + single-value `oeq`. Omit to include all plans. Use `exists=false` for credits + without plan restrictions. Feature and plan selections combine with AND. + plan_version: + allOf: + - $ref: '#/components/schemas/NumericFieldFilter' + description: |- + Select one positive integer plan version using `eq` (or shorthand). Requires a + concrete `plan_key`. Credits whose version constraints match this version, and + credits without plan restrictions, are included. Omit to include every version + of the selected plan. additionalProperties: false description: Filter options for listing credit transactions. ListCurrenciesParamsFilter: @@ -18193,6 +18226,47 @@ components: type: string pattern: ^\-?[0-9]+(\.[0-9]+)?$ description: Numeric represents an arbitrary precision number. + NumericFieldFilter: + anyOf: + - type: number + format: double + - type: object + properties: + eq: + type: number + format: double + description: Value strictly equals the given numeric value. + neq: + type: number + format: double + description: Value does not equal the given numeric value. + oeq: + type: string + description: Returns entities that match any of the comma-delimited numeric values. + lt: + type: number + format: double + description: Value is less than the given numeric value. + lte: + type: number + format: double + description: Value is less than or equal to the given numeric value. + gt: + type: number + format: double + description: Value is greater than the given numeric value. + gte: + type: number + format: double + description: Value is greater than or equal to the given numeric value. + additionalProperties: false + description: |- + Filter by a numeric value. All properties are optional; provide exactly one to + specify the comparison. + title: Numeric Field Filter + x-go-type-import: + path: github.com/openmeterio/openmeter/api/v3/filters + x-go-type: filters.FilterNumeric OrganizationDefaultTaxCodes: type: object required: diff --git a/openmeter/ledger/README.md b/openmeter/ledger/README.md index 8bf71dcb1d..ab00ee1bdf 100644 --- a/openmeter/ledger/README.md +++ b/openmeter/ledger/README.md @@ -195,19 +195,22 @@ affects normalization, routing-key versions, schema persistence, filters, account-specific route parameters, transaction rules, corrections, and historical data—not only the `Route` struct. -## Feature-restricted balances +## Filtered balances Public balance filtering and source allocability are related but distinct: -- no feature filter means the whole credit portfolio -- an unrestricted-only filter selects routes with no feature restriction -- filtering for one feature includes unrestricted routes and routes containing - that feature -- unrestricted credit can fund any charge; restricted credit can fund only a - matching feature +- omitting a dimension includes its entire portfolio +- selecting no restriction on a dimension does not remove other restrictions +- selecting one feature or plan includes routes unrestricted on that dimension + and routes matching the selection; an optional plan version is an exact value + matched against each grant's version constraint +- feature and plan selections combine with AND Public filtering cannot be implemented as exact route equality, and a public balance result is not necessarily the set of sources allocable to every charge. +The same selection applies to pending grants, live charge impacts, transaction +amounts, and before/after balances. Actual allocation still matches all charge +dimensions against each credit source. ## Time and balance boundaries diff --git a/openmeter/ledger/account/adapter/subaccount.go b/openmeter/ledger/account/adapter/subaccount.go index d71488ae28..270cebcb56 100644 --- a/openmeter/ledger/account/adapter/subaccount.go +++ b/openmeter/ledger/account/adapter/subaccount.go @@ -209,6 +209,9 @@ func (r *repo) ListSubAccounts(ctx context.Context, input ledgeraccount.ListSubA routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFiltersPredicate(s.C, exact)) }) } + if plan, ok := normalizedRoute.MatchPlan.Get(); ok { + routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) + } if features, ok := normalizedRoute.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } diff --git a/openmeter/ledger/breakage/adapter/query.go b/openmeter/ledger/breakage/adapter/query.go index e9f399459c..61f5b9d83f 100644 --- a/openmeter/ledger/breakage/adapter/query.go +++ b/openmeter/ledger/breakage/adapter/query.go @@ -10,7 +10,7 @@ import ( ) func expiredRecordRoutePredicate(route ledger.RouteFilter) (predicate.LedgerBreakageRecord, error) { - if route.Currency.Code == "" && route.CreditFilters.IsAbsent() && route.Features.IsAbsent() && route.MatchFeature == "" { + if route.Currency.Code == "" && route.CreditFilters.IsAbsent() && route.Features.IsAbsent() && route.MatchFeature == "" && route.MatchPlan.IsAbsent() { return nil, nil } diff --git a/openmeter/ledger/breakage/breakage_impacts.go b/openmeter/ledger/breakage/breakage_impacts.go index 76b70a744b..5a5d79815c 100644 --- a/openmeter/ledger/breakage/breakage_impacts.go +++ b/openmeter/ledger/breakage/breakage_impacts.go @@ -218,6 +218,12 @@ func (s *service) ListExpiredBreakageImpacts(ctx context.Context, input ListExpi func ValidateExpiredRouteFilter(route ledger.RouteFilter) error { var errs []error + if plan, ok := route.MatchPlan.Get(); ok && plan != nil { + if err := plan.ValidateAsPlanFilter(); err != nil { + errs = append(errs, fmt.Errorf("match plan: %w", err)) + } + } + if route.Features.IsPresent() && route.MatchFeature != "" { errs = append(errs, errors.New("features and match feature filters cannot be combined")) } diff --git a/openmeter/ledger/creditfilterquery/filters.go b/openmeter/ledger/creditfilterquery/filters.go index bf4de52b32..6111600cf4 100644 --- a/openmeter/ledger/creditfilterquery/filters.go +++ b/openmeter/ledger/creditfilterquery/filters.go @@ -32,6 +32,30 @@ func MatchFeaturePredicate(column func(string) string, feature string) *sql.Pred })) } +// MatchPlanPredicate selects the same plan view as Route.Matches, including +// credits without plan restrictions. A nil plan selects only those credits. +func MatchPlanPredicate(column func(string) string, plan *ledger.PlanFilter) *sql.Predicate { + unrestricted := sql.P(func(b *sql.Builder) { + b.WriteString("COALESCE(").Ident(column("filters")).WriteString("->'plans', '[]'::jsonb) = '[]'::jsonb") + }) + if plan == nil { + return unrestricted + } + + path := "$.plans[*] ? (@.key == $key)" + variables := map[string]any{"key": plan.Key} + if plan.Version != nil && plan.Version.Eq != nil { + path = "$.plans[*] ? (@.key == $key && (!exists(@.version) || @.version == null || @.version.eq == $version || @.version.in[*] == $version || @.version.gte <= $version || @.version.lte >= $version))" + variables["version"] = *plan.Version.Eq + } + encoded, _ := json.Marshal(variables) + + return sql.Or(unrestricted, sql.P(func(b *sql.Builder) { + b.WriteString("jsonb_path_exists(").Ident(column("filters")).WriteString(", ").Arg(path). + WriteString("::jsonpath, ").Arg(string(encoded)).WriteString("::jsonb)") + })) +} + // ExactFiltersPredicate compares complete normalized route restriction sets. func ExactFiltersPredicate(column func(string) string, filters ledger.CreditFilters) *sql.Predicate { encoded, _ := json.Marshal(filters.Normalize()) diff --git a/openmeter/ledger/creditfilters.go b/openmeter/ledger/creditfilters.go index 5e8c228555..5dc8c94825 100644 --- a/openmeter/ledger/creditfilters.go +++ b/openmeter/ledger/creditfilters.go @@ -151,6 +151,22 @@ func (f VersionFilter) Validate() error { return models.NewNillableGenericValidationError(errors.Join(errs...)) } +// ValidateAsPlanFilter validates a balance or transaction selection: one plan, +// optionally pinned to a concrete version. Grant restrictions may use ranges. +func (f PlanFilter) ValidateAsPlanFilter() error { + var errs []error + + if err := (CreditFilters{Plans: []PlanFilter{f}}).Validate(); err != nil { + errs = append(errs, err) + } + + if f.Version != nil && f.Version.Eq == nil { + errs = append(errs, errors.New("plan selection requires an exact version")) + } + + return models.NewNillableGenericValidationError(errors.Join(errs...)) +} + // effectiveVersion selects the oldest format that represents new filters. // Explicit versions are retained so stored filters keep their encoding contract. func (f CreditFilters) effectiveVersion() CreditFiltersVersion { diff --git a/openmeter/ledger/creditvoid/adapter/query.go b/openmeter/ledger/creditvoid/adapter/query.go index 3d04009082..6bc18ea4f5 100644 --- a/openmeter/ledger/creditvoid/adapter/query.go +++ b/openmeter/ledger/creditvoid/adapter/query.go @@ -10,7 +10,7 @@ import ( ) func voidRecordRoutePredicate(route ledger.RouteFilter) (predicate.LedgerCreditVoidRecord, error) { - if route.Currency.Code == "" && route.CreditFilters.IsAbsent() && route.Features.IsAbsent() && route.MatchFeature == "" { + if route.Currency.Code == "" && route.CreditFilters.IsAbsent() && route.Features.IsAbsent() && route.MatchFeature == "" && route.MatchPlan.IsAbsent() { return nil, nil } diff --git a/openmeter/ledger/customerbalance/README.md b/openmeter/ledger/customerbalance/README.md index bc93a34130..f7a5be4987 100644 --- a/openmeter/ledger/customerbalance/README.md +++ b/openmeter/ledger/customerbalance/README.md @@ -42,6 +42,17 @@ History before/after values remain booked balances. A grant of 10 followed by consumption of 4 shows `0 → 10`, then `10 → 6`, even though spendable credit before that consumption is already 6. +Balance and transaction queries support one feature key and one plan key, with +an optional exact plan version. Each selection includes credits unrestricted on +that dimension; feature and plan selections combine with AND. `exists=false` +selects no restriction on that dimension only. For example, feature-unrestricted +credits can still be plan-restricted. Querying a plan without a version includes +all its versions. The selection applies to pending/live balances and transaction +amounts and booked before/after balances before pagination; responses remain per currency. +History follows the selected credit routes, not the consuming charge's plan or +feature. Spending unrestricted credit therefore appears in every matching view, +even when another plan or feature consumed it. Filtered views overlap and must +not be added together. Fiat currencies are identified by code. Custom-currency balance rows also carry `custom_currency.id`; balances remain separate when historical managed diff --git a/openmeter/ledger/customerbalance/expired_loader.go b/openmeter/ledger/customerbalance/expired_loader.go index 9cf6db8533..3fa8061765 100644 --- a/openmeter/ledger/customerbalance/expired_loader.go +++ b/openmeter/ledger/customerbalance/expired_loader.go @@ -25,7 +25,7 @@ func (l *expiredCreditTransactionLoader) Load(ctx context.Context, input creditT After: input.After, Before: input.Before, Limit: input.Limit, - Route: featureFilterRoute(input.FeatureFilter), + Route: creditFilterRoute(input.FeatureFilter, input.PlanFilter), }) if err != nil { return creditTransactionLoaderResult{}, fmt.Errorf("list expired breakage impacts: %w", err) diff --git a/openmeter/ledger/customerbalance/facade.go b/openmeter/ledger/customerbalance/facade.go index 62aace6872..5357b17ae7 100644 --- a/openmeter/ledger/customerbalance/facade.go +++ b/openmeter/ledger/customerbalance/facade.go @@ -35,6 +35,7 @@ type GetBalancesInput struct { CustomerID customer.CustomerID Currencies CurrencyFilter FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] AsOf *time.Time } @@ -42,6 +43,7 @@ type GetBalanceInput struct { CustomerID customer.CustomerID Currency currencyx.Code FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] After *ledger.TransactionCursor AsOf *time.Time } @@ -61,6 +63,10 @@ func (i GetBalancesInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } + if i.AsOf != nil && i.AsOf.IsZero() { errs = append(errs, fmt.Errorf("asOf must not be zero")) } @@ -83,6 +89,10 @@ func (i GetBalanceInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } + if i.After != nil { if err := i.After.Validate(); err != nil { errs = append(errs, fmt.Errorf("after: %w", err)) @@ -149,6 +159,7 @@ func (f *Facade) GetBalances(ctx context.Context, input GetBalancesInput) ([]Bal CustomerID: input.CustomerID, Currencies: CurrencyFilter{Codes: codes}, FeatureFilter: input.FeatureFilter, + PlanFilter: input.PlanFilter, AsOf: input.AsOf, }) if err != nil { @@ -161,6 +172,7 @@ func (f *Facade) GetBalances(ctx context.Context, input GetBalancesInput) ([]Bal CustomerID: input.CustomerID, Currency: reference, FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + PlanFilter: input.PlanFilter, BalanceQuery: ledger.BalanceQuery{ AsOf: input.AsOf, }, @@ -192,6 +204,7 @@ func (f *Facade) GetBalance(ctx context.Context, input GetBalanceInput) (alpacad CustomerID: input.CustomerID, Currency: currencies.NewCurrencyReference(input.Currency), FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + PlanFilter: input.PlanFilter, BalanceQuery: ledger.BalanceQuery{ After: input.After, AsOf: input.AsOf, diff --git a/openmeter/ledger/customerbalance/feature_filter.go b/openmeter/ledger/customerbalance/filters.go similarity index 60% rename from openmeter/ledger/customerbalance/feature_filter.go rename to openmeter/ledger/customerbalance/filters.go index fb1bf3e694..b2ab1bcdc5 100644 --- a/openmeter/ledger/customerbalance/feature_filter.go +++ b/openmeter/ledger/customerbalance/filters.go @@ -9,8 +9,8 @@ import ( // Feature filters are represented as a tri-state option: // - None: all credit routes / total portfolio balance. -// - Some(nil): unrestricted routes only. -// - Some(one feature): unrestricted routes plus restricted routes containing that feature. +// - Some(nil): routes without feature restrictions. +// - Some(one feature): routes without feature restrictions plus routes containing that feature. func AllFeatureFilter() mo.Option[creditpurchase.FeatureFilters] { return mo.None[creditpurchase.FeatureFilters]() } @@ -49,15 +49,29 @@ func normalizeFeatureFilter(filter mo.Option[creditpurchase.FeatureFilters]) mo. return mo.Some(features.Normalize()) } -func featureFilterRoute(featureFilter mo.Option[creditpurchase.FeatureFilters]) ledger.RouteFilter { +func ValidatePlanFilter(filter mo.Option[*ledger.PlanFilter]) error { + if plan, ok := filter.Get(); ok && plan != nil { + return plan.ValidateAsPlanFilter() + } + + return nil +} + +// creditFilterRoute defines the queried balance view, including shared-credit +// exposure on each selected dimension. Actual credit matching is checked +// separately when live impacts are applied to concrete credit sources. +func creditFilterRoute(featureFilter mo.Option[creditpurchase.FeatureFilters], planFilter mo.Option[*ledger.PlanFilter]) ledger.RouteFilter { + route := ledger.RouteFilter{MatchPlan: planFilter} if featureFilter.IsAbsent() { - return ledger.RouteFilter{} + return route } features := featureFilter.OrEmpty() if features == nil { - return ledger.RouteFilter{Features: mo.Some[[]string](nil)} + route.Features = mo.Some[[]string](nil) + return route } - return ledger.RouteFilter{MatchFeature: features[0]} + route.MatchFeature = features[0] + return route } diff --git a/openmeter/ledger/customerbalance/funded_loader.go b/openmeter/ledger/customerbalance/funded_loader.go index 82a0e3070e..8f8cec6af3 100644 --- a/openmeter/ledger/customerbalance/funded_loader.go +++ b/openmeter/ledger/customerbalance/funded_loader.go @@ -111,7 +111,7 @@ func (l *fundedCreditTransactionLoader) listCandidatePage( EntryFilter: ledger.TransactionEntryFilter{ AccountIDs: accountIDs, Currency: input.Currency, - Route: featureFilterRoute(input.FeatureFilter), + Route: creditFilterRoute(input.FeatureFilter, input.PlanFilter), }, ReturnOnlyMatchingEntries: true, @@ -350,10 +350,11 @@ func (l *fundedCreditTransactionLoader) resolveBalances( // Calculate the impacts visible in the requested balance projection. impacts := unfilteredImpacts - if input.FeatureFilter.IsPresent() { + if input.FeatureFilter.IsPresent() || input.PlanFilter.IsPresent() { impacts, err = fundedCreditTransactionBalanceImpacts(group, GetBalanceServiceInput{ Currency: item.CurrencyReference(), FeatureFilter: input.FeatureFilter, + PlanFilter: input.PlanFilter, }) if err != nil { return nil, fmt.Errorf("resolve funded credit transaction group %s filtered balance impacts: %w", item.fundedTransactionGroupID, err) diff --git a/openmeter/ledger/customerbalance/ledger_loader.go b/openmeter/ledger/customerbalance/ledger_loader.go index 8aff5850b4..2839817361 100644 --- a/openmeter/ledger/customerbalance/ledger_loader.go +++ b/openmeter/ledger/customerbalance/ledger_loader.go @@ -34,7 +34,7 @@ func (l *ledgerCreditTransactionLoader) Load(ctx context.Context, input creditTr EntryFilter: ledger.TransactionEntryFilter{ AccountIDs: []string{input.AccountID}, Currency: input.Currency, - Route: featureFilterRoute(input.FeatureFilter), + Route: creditFilterRoute(input.FeatureFilter, input.PlanFilter), }, ReturnOnlyMatchingEntries: true, diff --git a/openmeter/ledger/customerbalance/loaders.go b/openmeter/ledger/customerbalance/loaders.go index b7fb783a9a..56ab04a116 100644 --- a/openmeter/ledger/customerbalance/loaders.go +++ b/openmeter/ledger/customerbalance/loaders.go @@ -22,6 +22,7 @@ type creditTransactionLoaderInput struct { Currency *currencyx.Code AsOf time.Time FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] } type creditTransactionLoaderResult struct { diff --git a/openmeter/ledger/customerbalance/plan_filter_test.go b/openmeter/ledger/customerbalance/plan_filter_test.go new file mode 100644 index 0000000000..bf748f75c0 --- /dev/null +++ b/openmeter/ledger/customerbalance/plan_filter_test.go @@ -0,0 +1,354 @@ +package customerbalance + +import ( + "testing" + "time" + + "github.com/alpacahq/alpacadecimal" + "github.com/samber/lo" + "github.com/samber/mo" + "github.com/stretchr/testify/require" + + "github.com/openmeterio/openmeter/openmeter/billing" + "github.com/openmeterio/openmeter/openmeter/billing/charges/creditpurchase" + "github.com/openmeterio/openmeter/openmeter/billing/charges/flatfee" + chargemeta "github.com/openmeterio/openmeter/openmeter/billing/charges/meta" + currenciestestutils "github.com/openmeterio/openmeter/openmeter/currencies/testutils" + "github.com/openmeterio/openmeter/openmeter/ledger" + "github.com/openmeterio/openmeter/openmeter/ledger/creditvoid" + "github.com/openmeterio/openmeter/openmeter/productcatalog" + "github.com/openmeterio/openmeter/pkg/clock" + "github.com/openmeterio/openmeter/pkg/timeutil" +) + +func TestPlanFilteredBalancesAndFundedTransactions(t *testing.T) { + env := newTestEnv(t) + // given: shared credits, all four version comparisons, and mixed dimensions. + grants := []struct { + amount int64 + filters ledger.CreditFilters + }{ + {100, ledger.CreditFilters{}}, + {10, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro"}}}}, + {20, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(1)}}}}}, + {30, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{In: []int{2, 3}}}}}}, + {40, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Gte: lo.ToPtr(3)}}}}}, + {50, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Lte: lo.ToPtr(2)}}}}}, + {60, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "enterprise", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}}}}, + {70, ledger.CreditFilters{Features: []string{"storage"}, Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}}}}, + {80, ledger.CreditFilters{Features: []string{"api"}, Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}}}}, + {90, ledger.CreditFilters{Features: []string{"api"}}}, + {15, ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(1)}}, {Key: "enterprise", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}}}}, + } + firstIssuedAt := env.Now() + for _, grant := range grants { + env.createCreditPurchase(t, alpacadecimal.NewFromInt(grant.amount), env.Currency, nil, grant.filters, creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{})) + env.createCreditPurchase(t, alpacadecimal.NewFromInt(grant.amount), env.Currency, nil, grant.filters, creditpurchase.NewInvoiceSettlement()) + clock.FreezeTime(env.Now().Add(time.Minute)) + defer clock.UnFreeze() + } + + for _, tc := range []struct { + name string + plan mo.Option[*ledger.PlanFilter] + feature mo.Option[creditpurchase.FeatureFilters] + want float64 + }{ + {name: "all", want: 565}, + {name: "no plan restriction", plan: mo.Some[*ledger.PlanFilter](nil), want: 190}, + {name: "all pro versions", plan: mo.Some(&ledger.PlanFilter{Key: "pro"}), want: 505}, + {name: "pro v1", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(1)}}), want: 285}, + {name: "pro v2", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), want: 430}, + {name: "pro v3", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(3)}}), want: 270}, + {name: "future pro version", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(4)}}), want: 240}, + {name: "unknown plan", plan: mo.Some(&ledger.PlanFilter{Key: "unknown"}), want: 190}, + {name: "pro v2 and api", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), feature: NewFeatureFilter([]string{"api"}), want: 360}, + {name: "pro v2 without feature restriction", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), feature: NewUnrestrictedFeatureFilter(), want: 190}, + {name: "fully unrestricted", plan: mo.Some[*ledger.PlanFilter](nil), feature: NewUnrestrictedFeatureFilter(), want: 100}, + } { + t.Run(tc.name, func(t *testing.T) { + // when: the same selection is applied to balances and paginated funding history. + facade, err := NewFacade(env.Service) + require.NoError(t, err) + balances, err := facade.GetBalances(t.Context(), GetBalancesInput{CustomerID: env.CustomerID, PlanFilter: tc.plan, FeatureFilter: tc.feature}) + require.NoError(t, err) + require.Len(t, balances, 1) + require.Equal(t, tc.want, balances[0].Balance.Booked().InexactFloat64()) + require.Equal(t, tc.want, balances[0].Balance.Spendable().InexactFloat64()) + require.Equal(t, tc.want, balances[0].Balance.Live().InexactFloat64()) + require.Equal(t, tc.want, balances[0].Balance.Pending().InexactFloat64()) + + input := ListCreditTransactionsInput{CustomerID: env.CustomerID, Limit: 2, PlanFilter: tc.plan, FeatureFilter: tc.feature} + var items []CreditTransaction + for { + page, err := env.Service.ListCreditTransactions(t.Context(), input) + require.NoError(t, err) + items = append(items, page.Items...) + if page.NextCursor == nil { + break + } + input.After = page.NextCursor + } + // then: pending grants never appear, and each page continues the same filtered balance. + remaining := tc.want + for _, item := range items { + require.Equal(t, CreditTransactionTypeFunded, item.Type) + require.Equal(t, remaining, item.Balance.After.InexactFloat64()) + remaining -= item.Amount.InexactFloat64() + require.Equal(t, remaining, item.Balance.Before.InexactFloat64()) + } + require.Zero(t, remaining) + + historical, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, Currency: env.CurrencyReference(), PlanFilter: tc.plan, FeatureFilter: tc.feature, + BalanceQuery: ledger.BalanceQuery{AsOf: &firstIssuedAt}, + }) + require.NoError(t, err) + require.Equal(t, float64(100), historical.Booked().InexactFloat64()) + require.Zero(t, historical.Live().InexactFloat64()) + }) + } +} + +func TestPlanFilteredExpiry(t *testing.T) { + env := newTestEnv(t) + issuedAt := env.Now() + expiresAt := issuedAt.Add(time.Hour) + // given: two plan-restricted grants expire together. + for _, key := range []string{"pro", "enterprise"} { + period := timeutil.ClosedPeriod{From: issuedAt, To: issuedAt} + _, err := env.creditPurchase.Create(t.Context(), creditpurchase.CreateInput{ + Namespace: env.Namespace, + Intent: creditpurchase.Intent{ + Intent: chargemeta.Intent{ + ManagedBy: billing.SystemManagedLine, CustomerID: env.CustomerID.ID, + Currency: currenciestestutils.NewFiatCurrency(t, env.Currency), + TaxConfig: productcatalog.TaxCodeConfig{TaxCodeID: env.taxCodeID}, + }, + IntentMutableFields: creditpurchase.IntentMutableFields{ + IntentMutableFields: chargemeta.IntentMutableFields{Name: "Expiring credit", ServicePeriod: period, BillingPeriod: period, FullServicePeriod: period}, + CreditAmount: alpacadecimal.NewFromInt(100), ExpiresAt: &expiresAt, + Filters: ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: key}}}, + Settlement: creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}), + }, + }, + }) + require.NoError(t, err) + } + // when: expiry is queried by plan, the other plan is excluded before pagination. + result, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, Limit: 1, AsOf: &expiresAt, + Type: lo.ToPtr(CreditTransactionTypeExpired), + PlanFilter: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), + }) + require.NoError(t, err) + // then: only this plan's credit expires and its filtered balance reaches zero. + require.Len(t, result.Items, 1) + require.Nil(t, result.NextCursor) + require.Equal(t, float64(-100), result.Items[0].Amount.InexactFloat64()) + require.Equal(t, float64(100), result.Items[0].Balance.Before.InexactFloat64()) + require.Zero(t, result.Items[0].Balance.After.InexactFloat64()) +} + +func TestPlanFilteredLiveConsumptionAndVoid(t *testing.T) { + env := newTestEnv(t) + // given: two independently restricted grants and charges with snapshotted plans. + period := timeutil.ClosedPeriod{From: env.Now(), To: env.Now().Add(time.Hour)} + var created []flatfee.Charge + var grants []creditpurchase.Charge + for _, key := range []string{"pro", "enterprise"} { + grants = append(grants, env.createCreditPurchase(t, alpacadecimal.NewFromInt(100), env.Currency, nil, + ledger.CreditFilters{Plans: []ledger.PlanFilter{{Key: key, Version: &ledger.VersionFilter{Gte: lo.ToPtr(2)}}}}, + creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{}))) + charges, err := env.flatFeeService.Create(t.Context(), flatfee.CreateInput{ + Namespace: env.Namespace, + Intents: []flatfee.Intent{{ + Intent: chargemeta.Intent{ + ManagedBy: billing.SystemManagedLine, CustomerID: env.CustomerID.ID, + Currency: currenciestestutils.NewFiatCurrency(t, env.Currency), + TaxConfig: productcatalog.TaxCodeConfig{TaxCodeID: env.taxCodeID}, + SubscriptionPlan: &chargemeta.SubscriptionPlan{Key: key, Version: 2}, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargemeta.IntentMutableFields{Name: "Plan charge", ServicePeriod: period, FullServicePeriod: period, BillingPeriod: period}, + InvoiceAt: env.Now(), PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(40), + }, + SettlementMode: productcatalog.CreditOnlySettlementMode, + }}, + }) + require.NoError(t, err) + created = append(created, charges[0].Charge) + } + + selection := mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}) + // when: a live balance is selected before either charge has been booked. + balance, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, Currency: env.CurrencyReference(), PlanFilter: selection, + }) + require.NoError(t, err) + // then: the other plan's pending charge does not reduce this balance. + require.Equal(t, float64(100), balance.Booked().InexactFloat64()) + require.Equal(t, float64(60), balance.Live().InexactFloat64()) + + clock.FreezeTime(period.To.Add(time.Second)) + defer clock.UnFreeze() + for _, charge := range created { + env.advanceFlatFeeCharge(t, charge) + } + consumed, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, Limit: 10, PlanFilter: selection, Type: lo.ToPtr(CreditTransactionTypeConsumed), + }) + require.NoError(t, err) + require.Len(t, consumed.Items, 1) + require.Equal(t, float64(-40), consumed.Items[0].Amount.InexactFloat64()) + require.Equal(t, float64(100), consumed.Items[0].Balance.Before.InexactFloat64()) + require.Equal(t, float64(60), consumed.Items[0].Balance.After.InexactFloat64()) + + // when: unused credit from both plans is voided, only this plan's movement is listed. + for _, grant := range grants { + _, err := env.CreditVoidService.VoidCreditPurchase(t.Context(), creditvoid.VoidCreditPurchaseInput{ + CustomerID: env.CustomerID, ChargeID: grant.ID, Currency: env.Currency, + }) + require.NoError(t, err) + } + voided, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, Limit: 10, PlanFilter: selection, Type: lo.ToPtr(CreditTransactionTypeVoided), + }) + require.NoError(t, err) + require.Len(t, voided.Items, 1) + require.Equal(t, float64(-60), voided.Items[0].Amount.InexactFloat64()) + require.Equal(t, float64(60), voided.Items[0].Balance.Before.InexactFloat64()) + require.Zero(t, voided.Items[0].Balance.After.InexactFloat64()) +} + +func TestPlanFilteredFundingAfterAdvance(t *testing.T) { + env := newTestEnv(t) + + // given: a plan-attributed charge creates an advance before shared credit arrives. + period := env.sp() + charges, err := env.flatFeeService.Create(t.Context(), flatfee.CreateInput{ + Namespace: env.Namespace, + Intents: []flatfee.Intent{{ + Intent: chargemeta.Intent{ + ManagedBy: billing.SystemManagedLine, CustomerID: env.CustomerID.ID, + Currency: currenciestestutils.NewFiatCurrency(t, env.Currency), + TaxConfig: productcatalog.TaxCodeConfig{TaxCodeID: env.taxCodeID}, + SubscriptionPlan: &chargemeta.SubscriptionPlan{Key: "pro", Version: 2}, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargemeta.IntentMutableFields{Name: "Plan charge", ServicePeriod: period, FullServicePeriod: period, BillingPeriod: period}, + InvoiceAt: env.Now(), PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(40), + }, + SettlementMode: productcatalog.CreditOnlySettlementMode, + }}, + }) + require.NoError(t, err) + clock.FreezeTime(period.To.Add(time.Second)) + defer clock.UnFreeze() + env.advanceFlatFeeCharge(t, charges[0].Charge) + env.createPromotionalCreditGrant(t, alpacadecimal.NewFromInt(100), env.Currency, nil) + + for _, tc := range []struct { + name string + plan mo.Option[*ledger.PlanFilter] + amount, before float64 + }{ + {name: "matching plan", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), amount: 100, before: -40}, + {name: "other plan", plan: mo.Some(&ledger.PlanFilter{Key: "enterprise"}), amount: 60}, + {name: "other version", plan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(1)}}), amount: 60}, + {name: "no plan restriction", plan: mo.Some[*ledger.PlanFilter](nil), amount: 60}, + } { + t.Run(tc.name, func(t *testing.T) { + // when: funding is viewed through one plan selection. + result, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ + CustomerID: env.CustomerID, Limit: 10, Type: lo.ToPtr(CreditTransactionTypeFunded), PlanFilter: tc.plan, + }) + require.NoError(t, err) + + // then: only the matching plan includes the cleared advance; shared issuance appears in every view. + require.Len(t, result.Items, 1) + require.Equal(t, tc.amount, result.Items[0].Amount.InexactFloat64()) + require.Equal(t, tc.before, result.Items[0].Balance.Before.InexactFloat64()) + require.Equal(t, float64(60), result.Items[0].Balance.After.InexactFloat64()) + }) + } +} + +func TestPlanFilteredHistoryMatchesFeatureFilteredSharedCredit(t *testing.T) { + env := newTestEnv(t) + issuedAt := env.Now() + + // given: a charge attributed to one plan and feature spends shared credit. + env.createPromotionalCreditGrant(t, alpacadecimal.NewFromInt(100), env.Currency, nil) + period := timeutil.ClosedPeriod{From: issuedAt.Add(time.Hour), To: issuedAt.Add(2 * time.Hour)} + created, err := env.flatFeeService.Create(t.Context(), flatfee.CreateInput{ + Namespace: env.Namespace, + Intents: []flatfee.Intent{{ + Intent: chargemeta.Intent{ + ManagedBy: billing.SystemManagedLine, CustomerID: env.CustomerID.ID, + Currency: currenciestestutils.NewFiatCurrency(t, env.Currency), + TaxConfig: productcatalog.TaxCodeConfig{TaxCodeID: env.taxCodeID}, + SubscriptionPlan: &chargemeta.SubscriptionPlan{Key: "pro", Version: 2}, + }, + IntentMutableFields: flatfee.IntentMutableFields{ + IntentMutableFields: chargemeta.IntentMutableFields{Name: "Shared credit consumption", ServicePeriod: period, FullServicePeriod: period, BillingPeriod: period}, + InvoiceAt: period.From, PaymentTerm: productcatalog.InAdvancePaymentTerm, + AmountBeforeProration: alpacadecimal.NewFromInt(40), + }, + FeatureKey: lo.ToPtr(testFeatureKey), + SettlementMode: productcatalog.CreditOnlySettlementMode, + }}, + }) + require.NoError(t, err) + clock.FreezeTime(period.To.Add(time.Second)) + defer clock.UnFreeze() + env.advanceFlatFeeCharge(t, created[0].Charge) + + for _, tc := range []struct { + name string + plan mo.Option[*ledger.PlanFilter] + feature mo.Option[creditpurchase.FeatureFilters] + }{ + {name: "consuming plan", plan: mo.Some(&ledger.PlanFilter{Key: "pro"})}, + {name: "other plan", plan: mo.Some(&ledger.PlanFilter{Key: "enterprise"})}, + {name: "unrestricted plans", plan: mo.Some[*ledger.PlanFilter](nil)}, + {name: "consuming feature", feature: NewFeatureFilter([]string{testFeatureKey})}, + {name: "other feature", feature: NewFeatureFilter([]string{"storage"})}, + {name: "unrestricted features", feature: NewUnrestrictedFeatureFilter()}, + } { + t.Run(tc.name, func(t *testing.T) { + // when: history is selected by credit eligibility, one row per page. + input := ListCreditTransactionsInput{CustomerID: env.CustomerID, Limit: 1, PlanFilter: tc.plan, FeatureFilter: tc.feature} + page, err := env.Service.ListCreditTransactions(t.Context(), input) + require.NoError(t, err) + + historical, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ + CustomerID: env.CustomerID, Currency: env.CurrencyReference(), + PlanFilter: tc.plan, FeatureFilter: tc.feature, + BalanceQuery: ledger.BalanceQuery{AsOf: &issuedAt}, + }) + require.NoError(t, err) + require.Equal(t, float64(100), historical.Booked().InexactFloat64()) + require.Equal(t, float64(60), historical.Spendable().InexactFloat64()) + + // then: every view includes consumption from the shared source, regardless of charge attribution. + require.Len(t, page.Items, 1) + require.Equal(t, CreditTransactionTypeConsumed, page.Items[0].Type) + require.Equal(t, float64(-40), page.Items[0].Amount.InexactFloat64()) + require.Equal(t, float64(100), page.Items[0].Balance.Before.InexactFloat64()) + require.Equal(t, float64(60), page.Items[0].Balance.After.InexactFloat64()) + require.NotNil(t, page.NextCursor) + input.After = page.NextCursor + page, err = env.Service.ListCreditTransactions(t.Context(), input) + require.NoError(t, err) + require.Len(t, page.Items, 1) + require.Equal(t, CreditTransactionTypeFunded, page.Items[0].Type) + require.Equal(t, float64(100), page.Items[0].Amount.InexactFloat64()) + require.Zero(t, page.Items[0].Balance.Before.InexactFloat64()) + require.Equal(t, float64(100), page.Items[0].Balance.After.InexactFloat64()) + require.Nil(t, page.NextCursor) + }) + } +} diff --git a/openmeter/ledger/customerbalance/service.go b/openmeter/ledger/customerbalance/service.go index b0ffd93029..9c06b51fb9 100644 --- a/openmeter/ledger/customerbalance/service.go +++ b/openmeter/ledger/customerbalance/service.go @@ -106,6 +106,7 @@ type GetBalanceServiceInput struct { CustomerID customer.CustomerID Currency currencies.CurrencyReference FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] BalanceQuery ledger.BalanceQuery } @@ -113,6 +114,7 @@ type GetBalanceCurrenciesInput struct { CustomerID customer.CustomerID Currencies CurrencyFilter FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] AsOf *time.Time } @@ -135,6 +137,10 @@ func (i GetBalanceServiceInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } + if i.BalanceQuery.After != nil { if err := i.BalanceQuery.After.Validate(); err != nil { errs = append(errs, fmt.Errorf("balance query after: %w", err)) @@ -193,6 +199,10 @@ func (i GetBalanceCurrenciesInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } + if i.AsOf != nil && i.AsOf.IsZero() { errs = append(errs, errors.New("asOf must not be zero")) } @@ -209,22 +219,22 @@ func (i GetBalanceCurrenciesInput) pendingGrantAsOf() time.Time { } func (i GetBalanceServiceInput) bookedRoute() ledger.RouteFilter { - route := i.featureRoute() + route := i.creditRoute() route.Currency = i.Currency.Clone() return route } func (i GetBalanceServiceInput) advanceRoute() ledger.RouteFilter { - route := i.featureRoute() + route := i.creditRoute() route.Currency = i.Currency.Clone() route.CostBasis = mo.Some[*alpacadecimal.Decimal](nil) return route } -func (i GetBalanceServiceInput) featureRoute() ledger.RouteFilter { - return featureFilterRoute(normalizeFeatureFilter(i.FeatureFilter)) +func (i GetBalanceServiceInput) creditRoute() ledger.RouteFilter { + return creditFilterRoute(normalizeFeatureFilter(i.FeatureFilter), i.PlanFilter) } func (c Config) Validate() error { @@ -309,7 +319,7 @@ func (s *service) GetBalance(ctx context.Context, input GetBalanceServiceInput) // queries return zero instead of mixing current impacts with a past ledger // balance that never represented one coherent point in time. if input.BalanceQuery.AsOf == nil { - impacts, err := s.getChargeLiveBalanceImpacts(ctx, input.CustomerID, input.Currency, normalizeFeatureFilter(input.FeatureFilter)) + impacts, err := s.getChargeLiveBalanceImpacts(ctx, input.CustomerID, input.Currency, input.creditRoute()) if err != nil { return nil, fmt.Errorf("get charge live balance impacts: %w", err) } @@ -318,7 +328,7 @@ func (s *service) GetBalance(ctx context.Context, input GetBalanceServiceInput) live = s.balanceCalculator.CalculateLiveBalanceFromSources(result.spendable, sources, impacts) } - pending, err := s.getPendingGrantAmount(ctx, input.CustomerID, input.Currency, normalizeFeatureFilter(input.FeatureFilter), input.pendingGrantAsOf()) + pending, err := s.getPendingGrantAmount(ctx, input.CustomerID, input.Currency, input.creditRoute(), input.pendingGrantAsOf()) if err != nil { return nil, fmt.Errorf("get pending grant amount: %w", err) } @@ -459,14 +469,14 @@ func (s *service) GetBalanceCurrencies(ctx context.Context, input GetBalanceCurr return nil, err } - pendingCurrencies, err := s.getPendingGrantCurrencies(ctx, input.CustomerID, normalizeFeatureFilter(input.FeatureFilter), asOf) + pendingCurrencies, err := s.getPendingGrantCurrencies(ctx, input.CustomerID, creditFilterRoute(normalizeFeatureFilter(input.FeatureFilter), input.PlanFilter), asOf) if err != nil { return nil, err } references := append(ledgerCurrencies, pendingCurrencies...) if input.AsOf == nil { - liveCurrencies, err := s.getChargeLiveBalanceCurrencies(ctx, input.CustomerID, normalizeFeatureFilter(input.FeatureFilter)) + liveCurrencies, err := s.getChargeLiveBalanceCurrencies(ctx, input.CustomerID, creditFilterRoute(normalizeFeatureFilter(input.FeatureFilter), input.PlanFilter)) if err != nil { return nil, err } @@ -612,7 +622,7 @@ func (s *service) getLedgerBalanceCurrencies(ctx context.Context, customerID cus func (s *service) getPendingGrantCurrencies( ctx context.Context, customerID customer.CustomerID, - featureFilter mo.Option[creditpurchase.FeatureFilters], + routeFilter ledger.RouteFilter, asOf time.Time, ) ([]currencies.CurrencyReference, error) { charges, err := s.listPendingGrantCandidateCharges(ctx, customerID) @@ -631,7 +641,7 @@ func (s *service) getPendingGrantCurrencies( continue } - if !featureFilterMatchesCreditPurchase(featureFilter, creditPurchaseCharge.Intent.Filters.Features) { + if !(ledger.Route{Filters: creditPurchaseCharge.Intent.Filters}).Matches(routeFilter) { continue } @@ -657,7 +667,7 @@ func (s *service) getPendingGrantAmount( ctx context.Context, customerID customer.CustomerID, currency currencies.CurrencyReference, - featureFilter mo.Option[creditpurchase.FeatureFilters], + routeFilter ledger.RouteFilter, asOf time.Time, ) (alpacadecimal.Decimal, error) { charges, err := s.listPendingGrantCandidateCharges(ctx, customerID) @@ -680,7 +690,7 @@ func (s *service) getPendingGrantAmount( continue } - if !featureFilterMatchesCreditPurchase(featureFilter, creditPurchaseCharge.Intent.Filters.Features) { + if !(ledger.Route{Filters: creditPurchaseCharge.Intent.Filters}).Matches(routeFilter) { continue } @@ -766,25 +776,7 @@ func canBecomeEffectiveLedgerCreditAt(charge creditpurchase.Charge, asOf time.Ti return true } -func featureFilterMatchesCreditPurchase(featureFilter mo.Option[creditpurchase.FeatureFilters], grantFeatures creditpurchase.FeatureFilters) bool { - if featureFilter.IsAbsent() { - return true - } - - grantFeatures = grantFeatures.Normalize() - filterFeatures := featureFilter.OrEmpty() - if filterFeatures == nil { - return len(grantFeatures) == 0 - } - - if len(grantFeatures) == 0 { - return true - } - - return len(filterFeatures) == 1 && slices.Contains(grantFeatures, filterFeatures[0]) -} - -func (s *service) getChargeLiveBalanceImpacts(ctx context.Context, customerID customer.CustomerID, currency currencies.CurrencyReference, featureFilter mo.Option[creditpurchase.FeatureFilters]) ([]Impact, error) { +func (s *service) getChargeLiveBalanceImpacts(ctx context.Context, customerID customer.CustomerID, currency currencies.CurrencyReference, routeFilter ledger.RouteFilter) ([]Impact, error) { items, err := s.listChargeLiveBalanceCandidates(ctx, customerID) if err != nil { return nil, err @@ -792,7 +784,7 @@ func (s *service) getChargeLiveBalanceImpacts(ctx context.Context, customerID cu impacts := make([]Impact, 0, len(items)) for _, charge := range items { - impact, err := s.getChargeLiveBalanceImpact(ctx, charge, currency, featureFilter) + impact, err := s.getChargeLiveBalanceImpact(ctx, charge, currency, routeFilter) if err != nil { return nil, err } @@ -807,7 +799,7 @@ func (s *service) getChargeLiveBalanceImpacts(ctx context.Context, customerID cu return impacts, nil } -func (s *service) getChargeLiveBalanceCurrencies(ctx context.Context, customerID customer.CustomerID, featureFilter mo.Option[creditpurchase.FeatureFilters]) ([]currencies.CurrencyReference, error) { +func (s *service) getChargeLiveBalanceCurrencies(ctx context.Context, customerID customer.CustomerID, routeFilter ledger.RouteFilter) ([]currencies.CurrencyReference, error) { items, err := s.listChargeLiveBalanceCandidates(ctx, customerID) if err != nil { return nil, err @@ -820,7 +812,7 @@ func (s *service) getChargeLiveBalanceCurrencies(ctx context.Context, customerID return nil, err } - impact, err := s.getChargeLiveBalanceImpact(ctx, charge, reference, featureFilter) + impact, err := s.getChargeLiveBalanceImpact(ctx, charge, reference, routeFilter) if err != nil { return nil, err } @@ -885,22 +877,22 @@ func (s *service) chargeCurrencyReference(charge charges.Charge) (currencies.Cur } } -func (s *service) getChargeLiveBalanceImpact(ctx context.Context, charge charges.Charge, currency currencies.CurrencyReference, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { +func (s *service) getChargeLiveBalanceImpact(ctx context.Context, charge charges.Charge, currency currencies.CurrencyReference, routeFilter ledger.RouteFilter) (*Impact, error) { if !chargeHasStarted(charge) { return nil, nil } switch charge.Type() { case meta.ChargeTypeFlatFee: - return getFlatFeeChargePendingBalanceImpact(charge, currency, featureFilter) + return getFlatFeeChargePendingBalanceImpact(charge, currency, routeFilter) case meta.ChargeTypeUsageBased: - return s.getUsageBasedChargePendingBalanceImpact(ctx, charge, currency, featureFilter) + return s.getUsageBasedChargePendingBalanceImpact(ctx, charge, currency, routeFilter) default: return nil, nil } } -func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency currencies.CurrencyReference, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { +func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency currencies.CurrencyReference, routeFilter ledger.RouteFilter) (*Impact, error) { flatFeeCharge, err := charge.AsFlatFeeCharge() if err != nil { return nil, fmt.Errorf("map flat fee charge: %w", err) @@ -910,14 +902,14 @@ func getFlatFeeChargePendingBalanceImpact(charge charges.Charge, currency curren return nil, nil } - if !featureFilterMatchesChargeFeatureKey(featureFilter, flatFeeCharge.Intent.GetFeatureKey()) { + if !(ledger.Route{Filters: flatFeeCharge.Intent.GetCreditFilters()}).Matches(routeFilter) { return nil, nil } return newImpactOrNil(charge, flatFeeCharge.State.AmountAfterProration) } -func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencies.CurrencyReference, featureFilter mo.Option[creditpurchase.FeatureFilters]) (*Impact, error) { +func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, charge charges.Charge, currency currencies.CurrencyReference, routeFilter ledger.RouteFilter) (*Impact, error) { usageBasedCharge, err := charge.AsUsageBasedCharge() if err != nil { return nil, fmt.Errorf("map usage based charge: %w", err) @@ -927,7 +919,7 @@ func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, c return nil, nil } - if !featureFilterMatchesChargeFeatureKey(featureFilter, usageBasedCharge.Intent.GetFeatureKey()) { + if !(ledger.Route{Filters: usageBasedCharge.Intent.GetCreditFilters()}).Matches(routeFilter) { return nil, nil } @@ -941,27 +933,6 @@ func (s *service) getUsageBasedChargePendingBalanceImpact(ctx context.Context, c return newImpactOrNil(charges.NewCharge(currentTotals.Charge), currentTotals.DueTotals.Total) } -// featureFilterMatchesChargeFeatureKey is query-scope matching: a feature -// balance view includes unrestricted charge impacts so it can show the customer's -// shared-credit exposure for that feature. Actual credit allocability is checked -// separately when live impacts are applied to concrete credit sources. -func featureFilterMatchesChargeFeatureKey(featureFilter mo.Option[creditpurchase.FeatureFilters], featureKey string) bool { - if featureFilter.IsAbsent() { - return true - } - - features := featureFilter.OrEmpty() - if features == nil { - return featureKey == "" - } - - if featureKey == "" { - return true - } - - return len(features) == 1 && features[0] == featureKey -} - func chargeHasStarted(charge charges.Charge) bool { now := clock.Now() diff --git a/openmeter/ledger/customerbalance/service_test.go b/openmeter/ledger/customerbalance/service_test.go index ec6f2756a6..8d73892ade 100644 --- a/openmeter/ledger/customerbalance/service_test.go +++ b/openmeter/ledger/customerbalance/service_test.go @@ -573,18 +573,18 @@ func TestFlatFeeCustomCurrencyLiveImpactUsesManagedIdentity(t *testing.T) { }) // The exact managed identity and a code-only filter both select the charge. - impact, err := getFlatFeeChargePendingBalanceImpact(charge, customCurrency.Reference(), AllFeatureFilter()) + impact, err := getFlatFeeChargePendingBalanceImpact(charge, customCurrency.Reference(), ledger.RouteFilter{}) require.NoError(t, err) require.NotNil(t, impact) require.Equal(t, float64(30), impact.UnboundedAmount().InexactFloat64()) - impact, err = getFlatFeeChargePendingBalanceImpact(charge, currencies.NewCurrencyReference("CREDITS"), AllFeatureFilter()) + impact, err = getFlatFeeChargePendingBalanceImpact(charge, currencies.NewCurrencyReference("CREDITS"), ledger.RouteFilter{}) require.NoError(t, err) require.NotNil(t, impact) // Reusing the display code does not let another managed currency's charge // affect this balance identity. - impact, err = getFlatFeeChargePendingBalanceImpact(charge, otherCurrency.Reference(), AllFeatureFilter()) + impact, err = getFlatFeeChargePendingBalanceImpact(charge, otherCurrency.Reference(), ledger.RouteFilter{}) require.NoError(t, err) require.Nil(t, impact) } diff --git a/openmeter/ledger/customerbalance/testenv_test.go b/openmeter/ledger/customerbalance/testenv_test.go index e044230256..c68b4855a4 100644 --- a/openmeter/ledger/customerbalance/testenv_test.go +++ b/openmeter/ledger/customerbalance/testenv_test.go @@ -648,13 +648,13 @@ func (e *testEnv) advanceFlatFeeCharge(t *testing.T, charge flatfee.Charge) flat func (e *testEnv) createPendingInvoiceCreditGrant(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code, features ...string) creditpurchase.Charge { t.Helper() - return e.createCreditPurchase(t, amount, currency, nil, creditpurchase.FeatureFilters(features), creditpurchase.NewInvoiceSettlement()) + return e.createCreditPurchase(t, amount, currency, nil, ledger.CreditFilters{Features: features}, creditpurchase.NewInvoiceSettlement()) } func (e *testEnv) createPromotionalCreditGrant(t *testing.T, amount alpacadecimal.Decimal, currency currencyx.Code, effectiveAt *time.Time, features ...string) creditpurchase.Charge { t.Helper() - return e.createCreditPurchase(t, amount, currency, effectiveAt, creditpurchase.FeatureFilters(features), creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{})) + return e.createCreditPurchase(t, amount, currency, effectiveAt, ledger.CreditFilters{Features: features}, creditpurchase.NewSettlement(creditpurchase.PromotionalSettlement{})) } func (e *testEnv) markCreditPurchaseDeleted(t *testing.T, charge creditpurchase.Charge) { @@ -672,7 +672,7 @@ func (e *testEnv) createCreditPurchase( amount alpacadecimal.Decimal, currency currencyx.Code, effectiveAt *time.Time, - features creditpurchase.FeatureFilters, + filters ledger.CreditFilters, settlement creditpurchase.Settlement, ) creditpurchase.Charge { t.Helper() @@ -713,7 +713,7 @@ func (e *testEnv) createCreditPurchase( }, CreditAmount: amount, EffectiveAt: effectiveAt, - Filters: ledger.CreditFilters{Version: ledger.CreditFiltersVersion1, Features: features}, + Filters: filters, Settlement: settlement, }, CostBasis: costBasis, diff --git a/openmeter/ledger/customerbalance/transaction_balances.go b/openmeter/ledger/customerbalance/transaction_balances.go index bb215d2c7f..5700582ab5 100644 --- a/openmeter/ledger/customerbalance/transaction_balances.go +++ b/openmeter/ledger/customerbalance/transaction_balances.go @@ -18,6 +18,7 @@ type resolveCreditTransactionBalancesInput struct { CustomerID customer.CustomerID Accounts customerBalanceAccounts FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] Items []CreditTransaction } @@ -35,6 +36,9 @@ func (i resolveCreditTransactionBalancesInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } return models.NewNillableGenericValidationError(errors.Join(errs...)) } @@ -56,6 +60,7 @@ func (s *service) resolveCreditTransactionBalances(ctx context.Context, input re CustomerID: input.CustomerID, Currency: item.CurrencyReference(), FeatureFilter: input.FeatureFilter, + PlanFilter: input.PlanFilter, BalanceQuery: item.balanceQuery(), } for _, scope := range []struct { diff --git a/openmeter/ledger/customerbalance/transactions.go b/openmeter/ledger/customerbalance/transactions.go index b3b429010c..1d212af616 100644 --- a/openmeter/ledger/customerbalance/transactions.go +++ b/openmeter/ledger/customerbalance/transactions.go @@ -50,6 +50,7 @@ type ListCreditTransactionsInput struct { AsOf *time.Time FeatureFilter mo.Option[creditpurchase.FeatureFilters] + PlanFilter mo.Option[*ledger.PlanFilter] } func (i ListCreditTransactionsInput) Validate() error { @@ -99,6 +100,10 @@ func (i ListCreditTransactionsInput) Validate() error { errs = append(errs, fmt.Errorf("feature filter: %w", err)) } + if err := ValidatePlanFilter(i.PlanFilter); err != nil { + errs = append(errs, fmt.Errorf("plan filter: %w", err)) + } + return models.NewNillableGenericValidationError(errors.Join(errs...)) } @@ -164,6 +169,7 @@ func (s *service) ListCreditTransactions(ctx context.Context, input ListCreditTr Currency: input.Currency, AsOf: creditTransactionsAsOf(input.AsOf), FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + PlanFilter: input.PlanFilter, } loadedLists := make([][]CreditTransaction, 0, len(loaders)) @@ -195,6 +201,7 @@ func (s *service) ListCreditTransactions(ctx context.Context, input ListCreditTr CustomerID: input.CustomerID, Accounts: accountIDs, FeatureFilter: normalizeFeatureFilter(input.FeatureFilter), + PlanFilter: input.PlanFilter, Items: items, }); err != nil { return ListCreditTransactionsResult{}, err diff --git a/openmeter/ledger/customerbalance/voided_loader.go b/openmeter/ledger/customerbalance/voided_loader.go index 57367509d7..f245351cee 100644 --- a/openmeter/ledger/customerbalance/voided_loader.go +++ b/openmeter/ledger/customerbalance/voided_loader.go @@ -25,7 +25,7 @@ func (l *voidedCreditTransactionLoader) Load(ctx context.Context, input creditTr After: input.After, Before: input.Before, Limit: input.Limit, - Route: featureFilterRoute(input.FeatureFilter), + Route: creditFilterRoute(input.FeatureFilter, input.PlanFilter), }) if err != nil { return creditTransactionLoaderResult{}, fmt.Errorf("list voided credit impacts: %w", err) diff --git a/openmeter/ledger/historical/adapter/ledger.go b/openmeter/ledger/historical/adapter/ledger.go index 051f97de39..3f5ed1a339 100644 --- a/openmeter/ledger/historical/adapter/ledger.go +++ b/openmeter/ledger/historical/adapter/ledger.go @@ -540,6 +540,9 @@ func listTransactionsRoutePredicates(currency *currencyx.Code, route ledger.Rout routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFiltersPredicate(s.C, exact)) }) } + if plan, ok := route.MatchPlan.Get(); ok { + routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) + } if features, ok := route.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } @@ -737,6 +740,9 @@ func scopedRouteSelectorPredicates(input scopedRouteSelectorPredicatesInput) ([] predicates = append(predicates, creditfilterquery.ExactFiltersPredicate(routeColumn, exact)) } + if plan, ok := route.MatchPlan.Get(); ok { + predicates = append(predicates, creditfilterquery.MatchPlanPredicate(routeColumn, plan)) + } if features, ok := route.Features.Get(); ok { predicates = append(predicates, creditfilterquery.ExactFeaturesPredicate(routeColumn, features)) } diff --git a/openmeter/ledger/historical/adapter/sumentries_query.go b/openmeter/ledger/historical/adapter/sumentries_query.go index b734a0b73f..dfed6616d0 100644 --- a/openmeter/ledger/historical/adapter/sumentries_query.go +++ b/openmeter/ledger/historical/adapter/sumentries_query.go @@ -182,6 +182,9 @@ func (b *sumEntriesQuery) subAccountPredicates() ([]predicate.LedgerSubAccount, routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFiltersPredicate(s.C, exact)) }) } + if plan, ok := normalizedRoute.MatchPlan.Get(); ok { + routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) + } if features, ok := normalizedRoute.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } diff --git a/openmeter/ledger/internal/routequery/subaccount_ids.go b/openmeter/ledger/internal/routequery/subaccount_ids.go index a783f8bf36..4eebcee4f2 100644 --- a/openmeter/ledger/internal/routequery/subaccount_ids.go +++ b/openmeter/ledger/internal/routequery/subaccount_ids.go @@ -93,6 +93,9 @@ func (q SubAccountIDsByRoute) selectorPredicates(routeColumn func(string) string predicates = append(predicates, creditfilterquery.ExactFiltersPredicate(routeColumn, exact)) } + if plan, ok := q.route.MatchPlan.Get(); ok { + predicates = append(predicates, creditfilterquery.MatchPlanPredicate(routeColumn, plan)) + } if features, ok := q.route.Features.Get(); ok { predicates = append(predicates, creditfilterquery.ExactFeaturesPredicate(routeColumn, features)) } diff --git a/openmeter/ledger/primitives.go b/openmeter/ledger/primitives.go index bd19931f0b..03a8c3fb73 100644 --- a/openmeter/ledger/primitives.go +++ b/openmeter/ledger/primitives.go @@ -58,7 +58,10 @@ type RouteFilter struct { Features mo.Option[[]string] // MatchFeature matches unrestricted routes and routes containing this single feature key. MatchFeature string - CostBasis mo.Option[*alpacadecimal.Decimal] + // MatchPlan includes routes unrestricted by plan and routes matching this plan. + // Absent means all routes; a present nil selects only routes without plan restrictions. + MatchPlan mo.Option[*PlanFilter] + CostBasis mo.Option[*alpacadecimal.Decimal] // CreditPriority is only meaningful for customer_fbo queries. CreditPriority *int @@ -349,6 +352,12 @@ func validateListTransactionsRouteFilter(route RouteFilter) error { errs = append(errs, errors.New("transaction authorization status filter is not supported")) } + if plan, ok := route.MatchPlan.Get(); ok && plan != nil { + if err := plan.ValidateAsPlanFilter(); err != nil { + errs = append(errs, fmt.Errorf("match plan: %w", err)) + } + } + if route.Features.IsPresent() && route.MatchFeature != "" { errs = append(errs, errors.New("features and match feature filters cannot be combined")) } diff --git a/openmeter/ledger/routing.go b/openmeter/ledger/routing.go index 6470d52684..0d118356fb 100644 --- a/openmeter/ledger/routing.go +++ b/openmeter/ledger/routing.go @@ -279,6 +279,15 @@ func (r Route) Filter() RouteFilter { } func (r Route) Matches(filter RouteFilter) bool { + if plan, ok := filter.MatchPlan.Get(); ok { + if plan == nil { + if len(r.Filters.Plans) > 0 { + return false + } + } else if !(CreditFilters{Plans: r.Filters.Plans}).Matches(Route{Filters: CreditFilters{Plans: []PlanFilter{*plan}}}) { + return false + } + } if exact, ok := filter.CreditFilters.Get(); ok && !r.Filters.Equal(exact) { return false } @@ -385,6 +394,13 @@ func (r Route) Normalize() (Route, error) { // Normalize canonicalizes route filter values before querying. func (f RouteFilter) Normalize() (RouteFilter, error) { + if plan, ok := f.MatchPlan.Get(); ok && plan != nil { + if err := plan.ValidateAsPlanFilter(); err != nil { + return RouteFilter{}, fmt.Errorf("match plan: %w", err) + } + normalized := (CreditFilters{Plans: []PlanFilter{*plan}}).Normalize() + f.MatchPlan = mo.Some(&normalized.Plans[0]) + } if exact, ok := f.CreditFilters.Get(); ok { if err := exact.Validate(); err != nil { return RouteFilter{}, err @@ -393,7 +409,7 @@ func (f RouteFilter) Normalize() (RouteFilter, error) { f.CreditFilters = mo.Some(exact.Normalize()) } - if f.Currency.Code == "" && f.CostBasisCurrency.IsAbsent() && f.TaxCode.IsAbsent() && f.Features.IsAbsent() && f.MatchFeature == "" && f.CostBasis.IsAbsent() && f.CreditPriority == nil && f.TransactionAuthorizationStatus == nil && f.TaxBehavior.IsAbsent() { + if f.Currency.Code == "" && f.CostBasisCurrency.IsAbsent() && f.TaxCode.IsAbsent() && f.Features.IsAbsent() && f.MatchFeature == "" && f.MatchPlan.IsAbsent() && f.CostBasis.IsAbsent() && f.CreditPriority == nil && f.TransactionAuthorizationStatus == nil && f.TaxBehavior.IsAbsent() { return f, nil } @@ -472,6 +488,7 @@ func (f RouteFilter) Normalize() (RouteFilter, error) { TaxBehavior: normalizedTaxBehavior, Features: normalizedFeatures, MatchFeature: f.MatchFeature, + MatchPlan: f.MatchPlan, CostBasis: normalizedCostBasis, CreditPriority: normalized.CreditPriority, TransactionAuthorizationStatus: normalized.TransactionAuthorizationStatus, From c0c4e4da94c2ad6571929140e266d32441a55c40 Mon Sep 17 00:00:00 2001 From: Alex Goth <64845621+GAlexIHU@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:59:04 +0200 Subject: [PATCH 2/3] test(ledger): cover plan filter SQL templates --- .../routequery/subaccount_ids_test.go | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/openmeter/ledger/internal/routequery/subaccount_ids_test.go b/openmeter/ledger/internal/routequery/subaccount_ids_test.go index e707c6f7c4..6ae5186c2a 100644 --- a/openmeter/ledger/internal/routequery/subaccount_ids_test.go +++ b/openmeter/ledger/internal/routequery/subaccount_ids_test.go @@ -3,6 +3,7 @@ package routequery import ( "testing" + "github.com/samber/lo" "github.com/samber/mo" "github.com/stretchr/testify/require" @@ -51,6 +52,61 @@ func TestSubAccountIDsByRouteSQL(t *testing.T) { `["feature-a"]`, }, }, + { + name: "without plan restrictions", + route: ledger.RouteFilter{ + MatchPlan: mo.Some[*ledger.PlanFilter](nil), + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE COALESCE("lsar"."filters"->'plans', '[]'::jsonb) = '[]'::jsonb`, + }, + { + name: "match plan key", + route: ledger.RouteFilter{ + Currency: currencies.NewCurrencyReference(currencyx.Code("USD")), + MatchPlan: mo.Some(&ledger.PlanFilter{Key: "pro"}), + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE "lsar"."currency" = $1 AND (COALESCE("lsar"."filters"->'plans', '[]'::jsonb) = '[]'::jsonb OR jsonb_path_exists("lsar"."filters", $2::jsonpath, $3::jsonb))`, + wantArgs: []any{ + "USD", + `$.plans[*] ? (@.key == $key)`, + `{"key":"pro"}`, + }, + }, + { + name: "match plan version", + route: ledger.RouteFilter{ + MatchPlan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE COALESCE("lsar"."filters"->'plans', '[]'::jsonb) = '[]'::jsonb OR jsonb_path_exists("lsar"."filters", $1::jsonpath, $2::jsonb)`, + wantArgs: []any{ + `$.plans[*] ? (@.key == $key && (!exists(@.version) || @.version == null || @.version.eq == $version || @.version.in[*] == $version || @.version.gte <= $version || @.version.lte >= $version))`, + `{"key":"pro","version":2}`, + }, + }, + { + name: "match feature and plan version", + route: ledger.RouteFilter{ + MatchFeature: "feature-a", + MatchPlan: mo.Some(&ledger.PlanFilter{Key: "pro", Version: &ledger.VersionFilter{Eq: lo.ToPtr(2)}}), + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE (COALESCE("lsar"."filters"->'plans', '[]'::jsonb) = '[]'::jsonb OR jsonb_path_exists("lsar"."filters", $1::jsonpath, $2::jsonb)) AND ("lsar"."filters"->'features' IS NULL OR "lsar"."filters"->'features' @> $3::jsonb)`, + wantArgs: []any{ + `$.plans[*] ? (@.key == $key && (!exists(@.version) || @.version == null || @.version.eq == $version || @.version.in[*] == $version || @.version.gte <= $version || @.version.lte >= $version))`, + `{"key":"pro","version":2}`, + `["feature-a"]`, + }, + }, + { + name: "plan key stays a bound JSON value", + route: ledger.RouteFilter{ + MatchPlan: mo.Some(&ledger.PlanFilter{Key: `pro'"\beta`}), + }, + wantSQL: `SELECT "lsa"."id" FROM "ledger_sub_accounts" AS "lsa" JOIN "ledger_sub_account_routes" AS "lsar" ON "lsa"."route_id" = "lsar"."id" WHERE COALESCE("lsar"."filters"->'plans', '[]'::jsonb) = '[]'::jsonb OR jsonb_path_exists("lsar"."filters", $1::jsonpath, $2::jsonb)`, + wantArgs: []any{ + `$.plans[*] ? (@.key == $key)`, + `{"key":"pro'\"\\beta"}`, + }, + }, { name: "unresolved custom currency code prefix", route: ledger.RouteFilter{ From a046242c15e10ce421ada07812cee37ff119981f Mon Sep 17 00:00:00 2001 From: Alex Goth <64845621+GAlexIHU@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:48:09 +0100 Subject: [PATCH 3/3] chore(credits): apply plan filter lint fixes --- api/v3/handlers/customers/credits/plan_filter.go | 3 +++ api/v3/handlers/customers/credits/plan_filter_test.go | 5 ++++- openmeter/ledger/account/adapter/subaccount.go | 1 + openmeter/ledger/creditfilterquery/filters.go | 1 + openmeter/ledger/customerbalance/filters.go | 1 + openmeter/ledger/customerbalance/plan_filter_test.go | 6 ++++++ openmeter/ledger/customerbalance/transaction_balances.go | 1 + openmeter/ledger/historical/adapter/ledger.go | 2 ++ openmeter/ledger/historical/adapter/sumentries_query.go | 1 + openmeter/ledger/internal/routequery/subaccount_ids.go | 1 + openmeter/ledger/routing.go | 3 +++ 11 files changed, 24 insertions(+), 1 deletion(-) diff --git a/api/v3/handlers/customers/credits/plan_filter.go b/api/v3/handlers/customers/credits/plan_filter.go index 3329c3c672..7fc8d7fb30 100644 --- a/api/v3/handlers/customers/credits/plan_filter.go +++ b/api/v3/handlers/customers/credits/plan_filter.go @@ -28,16 +28,19 @@ func fromAPICustomerCreditPlanFilter(ctx context.Context, key *api.StringFieldFi if *key.Exists || key.Eq != nil || len(key.Oeq) > 0 { return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", errors.New("plan_key: only exists=false without other operators is supported")) } + selected = mo.Some[*ledger.PlanFilter](nil) } else { keys := slices.Clone(key.Oeq) if key.Eq != nil { keys = append(keys, *key.Eq) } + keys = lo.Uniq(keys) if len(keys) > 1 { return selected, newPlanFilterBadRequest(ctx, "filter[plan_key]", errors.New("plan_key: exactly one plan key is supported")) } + if len(keys) == 1 { selected = mo.Some(&ledger.PlanFilter{Key: keys[0]}) } diff --git a/api/v3/handlers/customers/credits/plan_filter_test.go b/api/v3/handlers/customers/credits/plan_filter_test.go index 43fc3facf9..48d77f3a83 100644 --- a/api/v3/handlers/customers/credits/plan_filter_test.go +++ b/api/v3/handlers/customers/credits/plan_filter_test.go @@ -1,7 +1,6 @@ package customerscredits import ( - "github.com/openmeterio/openmeter/api/v3/apierrors" "net/http" "net/url" "testing" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" api "github.com/openmeterio/openmeter/api/v3" + "github.com/openmeterio/openmeter/api/v3/apierrors" "github.com/openmeterio/openmeter/api/v3/filters" "github.com/openmeterio/openmeter/openmeter/ledger" ) @@ -48,6 +48,7 @@ func TestCustomerCreditPlanFilterQuery(t *testing.T) { if err := filters.Parse(values, ¶ms); err != nil { return mo.None[*ledger.PlanFilter](), err } + return fromAPICustomerCreditPlanFilter(t.Context(), params.PlanKey, params.PlanVersion) }, func() (mo.Option[*ledger.PlanFilter], error) { @@ -55,6 +56,7 @@ func TestCustomerCreditPlanFilterQuery(t *testing.T) { if err := filters.Parse(values, ¶ms); err != nil { return mo.None[*ledger.PlanFilter](), err } + return fromAPICustomerCreditPlanFilter(t.Context(), params.PlanKey, params.PlanVersion) }, } { @@ -63,6 +65,7 @@ func TestCustomerCreditPlanFilterQuery(t *testing.T) { require.Error(t, err) continue } + require.NoError(t, err) require.Equal(t, tc.want, got) } diff --git a/openmeter/ledger/account/adapter/subaccount.go b/openmeter/ledger/account/adapter/subaccount.go index 270cebcb56..22917ab259 100644 --- a/openmeter/ledger/account/adapter/subaccount.go +++ b/openmeter/ledger/account/adapter/subaccount.go @@ -212,6 +212,7 @@ func (r *repo) ListSubAccounts(ctx context.Context, input ledgeraccount.ListSubA if plan, ok := normalizedRoute.MatchPlan.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) } + if features, ok := normalizedRoute.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } diff --git a/openmeter/ledger/creditfilterquery/filters.go b/openmeter/ledger/creditfilterquery/filters.go index 6111600cf4..310859eb27 100644 --- a/openmeter/ledger/creditfilterquery/filters.go +++ b/openmeter/ledger/creditfilterquery/filters.go @@ -48,6 +48,7 @@ func MatchPlanPredicate(column func(string) string, plan *ledger.PlanFilter) *sq path = "$.plans[*] ? (@.key == $key && (!exists(@.version) || @.version == null || @.version.eq == $version || @.version.in[*] == $version || @.version.gte <= $version || @.version.lte >= $version))" variables["version"] = *plan.Version.Eq } + encoded, _ := json.Marshal(variables) return sql.Or(unrestricted, sql.P(func(b *sql.Builder) { diff --git a/openmeter/ledger/customerbalance/filters.go b/openmeter/ledger/customerbalance/filters.go index b2ab1bcdc5..baaf47ac3f 100644 --- a/openmeter/ledger/customerbalance/filters.go +++ b/openmeter/ledger/customerbalance/filters.go @@ -73,5 +73,6 @@ func creditFilterRoute(featureFilter mo.Option[creditpurchase.FeatureFilters], p } route.MatchFeature = features[0] + return route } diff --git a/openmeter/ledger/customerbalance/plan_filter_test.go b/openmeter/ledger/customerbalance/plan_filter_test.go index bf748f75c0..74a71f4f94 100644 --- a/openmeter/ledger/customerbalance/plan_filter_test.go +++ b/openmeter/ledger/customerbalance/plan_filter_test.go @@ -87,8 +87,10 @@ func TestPlanFilteredBalancesAndFundedTransactions(t *testing.T) { if page.NextCursor == nil { break } + input.After = page.NextCursor } + // then: pending grants never appear, and each page continues the same filtered balance. remaining := tc.want for _, item := range items { @@ -97,6 +99,7 @@ func TestPlanFilteredBalancesAndFundedTransactions(t *testing.T) { remaining -= item.Amount.InexactFloat64() require.Equal(t, remaining, item.Balance.Before.InexactFloat64()) } + require.Zero(t, remaining) historical, err := env.Service.GetBalance(t.Context(), GetBalanceServiceInput{ @@ -135,6 +138,7 @@ func TestPlanFilteredExpiry(t *testing.T) { }) require.NoError(t, err) } + // when: expiry is queried by plan, the other plan is excluded before pagination. result, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ CustomerID: env.CustomerID, Limit: 1, AsOf: &expiresAt, @@ -196,6 +200,7 @@ func TestPlanFilteredLiveConsumptionAndVoid(t *testing.T) { for _, charge := range created { env.advanceFlatFeeCharge(t, charge) } + consumed, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ CustomerID: env.CustomerID, Limit: 10, PlanFilter: selection, Type: lo.ToPtr(CreditTransactionTypeConsumed), }) @@ -212,6 +217,7 @@ func TestPlanFilteredLiveConsumptionAndVoid(t *testing.T) { }) require.NoError(t, err) } + voided, err := env.Service.ListCreditTransactions(t.Context(), ListCreditTransactionsInput{ CustomerID: env.CustomerID, Limit: 10, PlanFilter: selection, Type: lo.ToPtr(CreditTransactionTypeVoided), }) diff --git a/openmeter/ledger/customerbalance/transaction_balances.go b/openmeter/ledger/customerbalance/transaction_balances.go index 5700582ab5..084901f894 100644 --- a/openmeter/ledger/customerbalance/transaction_balances.go +++ b/openmeter/ledger/customerbalance/transaction_balances.go @@ -39,6 +39,7 @@ func (i resolveCreditTransactionBalancesInput) Validate() error { if err := ValidatePlanFilter(i.PlanFilter); err != nil { errs = append(errs, fmt.Errorf("plan filter: %w", err)) } + return models.NewNillableGenericValidationError(errors.Join(errs...)) } diff --git a/openmeter/ledger/historical/adapter/ledger.go b/openmeter/ledger/historical/adapter/ledger.go index 3f5ed1a339..35d29373f3 100644 --- a/openmeter/ledger/historical/adapter/ledger.go +++ b/openmeter/ledger/historical/adapter/ledger.go @@ -543,6 +543,7 @@ func listTransactionsRoutePredicates(currency *currencyx.Code, route ledger.Rout if plan, ok := route.MatchPlan.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) } + if features, ok := route.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } @@ -743,6 +744,7 @@ func scopedRouteSelectorPredicates(input scopedRouteSelectorPredicatesInput) ([] if plan, ok := route.MatchPlan.Get(); ok { predicates = append(predicates, creditfilterquery.MatchPlanPredicate(routeColumn, plan)) } + if features, ok := route.Features.Get(); ok { predicates = append(predicates, creditfilterquery.ExactFeaturesPredicate(routeColumn, features)) } diff --git a/openmeter/ledger/historical/adapter/sumentries_query.go b/openmeter/ledger/historical/adapter/sumentries_query.go index dfed6616d0..11c25b068c 100644 --- a/openmeter/ledger/historical/adapter/sumentries_query.go +++ b/openmeter/ledger/historical/adapter/sumentries_query.go @@ -185,6 +185,7 @@ func (b *sumEntriesQuery) subAccountPredicates() ([]predicate.LedgerSubAccount, if plan, ok := normalizedRoute.MatchPlan.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.MatchPlanPredicate(s.C, plan)) }) } + if features, ok := normalizedRoute.Features.Get(); ok { routePredicates = append(routePredicates, func(s *sql.Selector) { s.Where(creditfilterquery.ExactFeaturesPredicate(s.C, features)) }) } diff --git a/openmeter/ledger/internal/routequery/subaccount_ids.go b/openmeter/ledger/internal/routequery/subaccount_ids.go index 4eebcee4f2..d64e9beb8a 100644 --- a/openmeter/ledger/internal/routequery/subaccount_ids.go +++ b/openmeter/ledger/internal/routequery/subaccount_ids.go @@ -96,6 +96,7 @@ func (q SubAccountIDsByRoute) selectorPredicates(routeColumn func(string) string if plan, ok := q.route.MatchPlan.Get(); ok { predicates = append(predicates, creditfilterquery.MatchPlanPredicate(routeColumn, plan)) } + if features, ok := q.route.Features.Get(); ok { predicates = append(predicates, creditfilterquery.ExactFeaturesPredicate(routeColumn, features)) } diff --git a/openmeter/ledger/routing.go b/openmeter/ledger/routing.go index 0d118356fb..31ed69b3e7 100644 --- a/openmeter/ledger/routing.go +++ b/openmeter/ledger/routing.go @@ -288,6 +288,7 @@ func (r Route) Matches(filter RouteFilter) bool { return false } } + if exact, ok := filter.CreditFilters.Get(); ok && !r.Filters.Equal(exact) { return false } @@ -398,9 +399,11 @@ func (f RouteFilter) Normalize() (RouteFilter, error) { if err := plan.ValidateAsPlanFilter(); err != nil { return RouteFilter{}, fmt.Errorf("match plan: %w", err) } + normalized := (CreditFilters{Plans: []PlanFilter{*plan}}).Normalize() f.MatchPlan = mo.Some(&normalized.Plans[0]) } + if exact, ok := f.CreditFilters.Get(); ok { if err := exact.Validate(); err != nil { return RouteFilter{}, err