Skip to main content

Price matrix gotchas

The PricePlanMatrix engine is powerful precisely because it lets you stack container-level filters, matrix columns, and EL expressions on top of each other. That flexibility is also where pricing bugs hide. These are the gotchas that show up once a matrix moves past the simple "one price" case.

Always ship a fallback line, or matching can return nothing

Line selection walks the matrix ordered by priority ascending, then ratingAccuracy descending, and returns the first line whose columns all match. If nothing matches and there is no default line, rating has nowhere to fall back to.

{ "priority": 99, "ratingAccuracy": 0, "values": [null, null, null], "price": 600 }

ratingAccuracy=0 with every column value null is the deliberate catch-all. Give it the highest priority number so specific lines are always tried first, but make sure it exists at all. A matrix with three specific lines and no default line will rate correctly right up until a customer falls outside all three.

ratingAccuracy is not computed for you, you have to set it right

ratingAccuracy is supposed to reflect how many non-null column values a line has, so more specific lines win over vaguer ones at the same priority. Nothing enforces that the number you enter actually matches the values you filled in. If you add a fourth column value to a line and forget to bump ratingAccuracy from 2 to 3, a less specific line can start winning the match ahead of it.

ratingAccuracyMeaning
0Default / fallback, matches anything
1One specific column value set
3Three specific column values set, most specific

Only a PUBLISHED version in date range is visible to rating

Version selection filters on status = 'PUBLISHED' and operationDate BETWEEN validity.from AND validity.to. A DRAFT version sitting there with the right dates will never be picked up. Neither will a PUBLISHED version whose validity window has already passed, even if it looks like the "current" one in the list UI. If a price looks stale, check the version's validity.to before you check the line values.

Container-level filters use null as wildcard, not as "unset"

The PricePlanMatrix container itself filters on seller, offer, country, currency, and quantity range before any matrix line is even evaluated:

WHERE (ppm.seller IS NULL OR ppm.seller = :seller)
AND (ppm.offerTemplate IS NULL OR ppm.offerTemplate = :offer)
AND (ppm.tradingCountry IS NULL OR ppm.tradingCountry = :country)

A null on any of these fields means "matches every value," not "this filter is inactive." If two price plans both target the same charge template and one has seller = null, that plan competes for every seller, not just the ones without a specific plan. Order plans by explicit priority when more than one could plausibly match.

Pick the column type for how the value actually varies, not for convenience

TypeUse for
STRINGExact text match
LIST_TEXT / LIST_MULTIPLE_TEXTValue is one of / all of a fixed set
Range_NumericTiered pricing by quantity (token counts, GB usage)
Range_DateTime-bound pricing windows
EXPRESSION_LANGUAGEAnything that needs logic beyond equality or range

Tiered usage pricing (first N units free, then a rate) is a Range_Numeric column, not three STRING lines with manually maintained thresholds. Getting this wrong is usually what turns a two-tier price plan into an unmaintainable pile of LIST_NUMERIC rows.

Multi-instance products need EL matching, not one charge template per instance

If a subscription has several instances of the same product (three SIM cards on one account, say), a naive setup creates one UsageChargeTemplate per instance with a static filterParam1. That works but does not scale past a handful of instances. The better pattern is one shared charge template with a dynamic filterExpression that checks a custom field on the instance against the incoming EDR:

filterExpression: #{ci.serviceInstance.getCfValue('sim_number') == edr.parameter1}

ci (the ChargeInstance, and through it serviceInstance.attributeInstances) and edr (the incoming record with all its parameters) are both available in that expression. This is the same EL-first pattern used elsewhere in the catalog: prefer one flexible rule over many static ones.

filterParam and filterExpression combine, they do not replace each other

filterParam1-filterParam4 do a coarse database-level match before any instance is loaded; filterExpression runs after, per candidate. Setting a filterParam1 prefix alongside a filterExpression narrows the SQL query first (cheap) and only evaluates the EL expression against the instances that already passed (fewer of them). For products with a lot of instances, skipping the filterParam narrowing and relying on filterExpression alone means every instance gets evaluated on every EDR.

Invoicing calendar can be overridden per charge, and that changes what gets billed when

Each ChargeInstance can carry its own invoicingCalendar, which overrides the subscription's billing cycle for that one charge:

BillingAccount.billingCycle (default)
→ Subscription.billingCycle (override)
→ ChargeInstance.invoicingCalendar (override)

Three SIM cards on the same subscription can legitimately bill on the 1st, the 15th, and weekly, each using its own charge-level override. This is a feature, not a bug, but it means "why is this one line item invoicing on a different date than the rest of the subscription" is almost always a charge-level invoicingCalendar override, not a billing run misconfiguration.