Skip to main content

Quickstart: pricing models and discounts

This walks three pricing models on the same charge-pricing draft/publish facade (flat, matrix, formula), then discount plans of several types applied on a real order and in the order preview. Every call ran live against http://localhost:8080. Defects surfaced during the recordings of this quickstart (order-time matrix rating, discount plan creation, and discounts missing from the preview); all are fixed on feature/v1-full-api and the affected steps were re-run live against the fixed build. One documented contract limit remains on CREATE: one discount plan per selection level rates at order time (see the step 9 caution).

Step count: 11 steps, all run live.

Step 1: one-shot flat price (baseline)

Already covered in full in the catalog-to-first-invoice quickstart: PUT /api/v1/charges/{code}/pricing/draft with {"pricingModel":"FLAT","price": 49.00,"currency":"EUR", ...}, then POST .../pricing/publish. Result: a published FLAT pricing version at 49.00 EUR.

Step 2: recurring charge with matrix pricing

Matrix pricing prices by a combination of dimension values instead of a single scalar. Each dimension is backed by a catalog Attribute (so the same attribute can drive both the pricing matrix and the product's own selectable options).

Create the attribute first:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_ATTR_PLAN",
"description":"Quickstart plan tier",
"attributeType":"LIST_TEXT",
"allowedValues":["BASIC","PREMIUM"]
}' \
http://localhost:8080/api/v1/attributes

Response (201):

{"id":60,"code":"QS1_ATTR_PLAN","description":"Quickstart plan tier","attributeType":"LIST_TEXT","attributeCategory":"REGULAR","disabled":false,"allowedValues":["BASIC","PREMIUM"],"unitNbDecimal":12,"auditable":{...}}

Create a RECURRING charge (recurring charges require a billingCalendar, unlike one-shot charges — a first attempt without one returned 400 with "The following parameters are required or contain invalid values: calendar"):

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_CHG_MATRIX",
"description":"Quickstart matrix-priced recurring fee",
"type":"RECURRING",
"invoiceSubCategory":"ISCAT_DEFAULT",
"taxClass":"NORMAL",
"billingCalendar":"MONTHLY"
}' \
http://localhost:8080/api/v1/charges

Add the matrix pricing draft: two rows keyed by the PLAN dimension (backed by QS1_ATTR_PLAN), BASIC at 15 EUR, PREMIUM at 35 EUR:

curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"pricingModel":"MATRIX",
"currency":"EUR",
"validFrom":"2026-01-01",
"columns":[{"code":"PLAN","type":"String","position":0,"attributeCode":"QS1_ATTR_PLAN"}],
"rows":[
{"price":15.00,"values":[{"dimensionCode":"PLAN","stringValue":"BASIC"}]},
{"price":35.00,"values":[{"dimensionCode":"PLAN","stringValue":"PREMIUM"}]}
]
}' \
http://localhost:8080/api/v1/charges/QS1_CHG_MATRIX/pricing/draft

Response (201): the columns[0].allowedValues is auto-populated from the attribute (["BASIC","PREMIUM"]) — you do not have to repeat it:

{"pricingVersions":[{"version":1,"pricingModel":"MATRIX","status":"DRAFT","columns":[{"code":"PLAN","label":"Quickstart plan tier","type":"String","position":0,"attributeCode":"QS1_ATTR_PLAN","isRange":false,"allowedValues":["BASIC","PREMIUM"]}],"rows":[{"priority":0,"values":[{"dimensionCode":"PLAN","stringValue":"BASIC"}],"price":15.000000000000},{"priority":1,"values":[{"dimensionCode":"PLAN","stringValue":"PREMIUM"}],"price":35.000000000000}]}]}

Publish it the same way as any other pricing draft:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
http://localhost:8080/api/v1/charges/QS1_CHG_MATRIX/pricing/publish

Response (200): status: PUBLISHED on the version, status: ACTIVE on the charge — identical publish mechanics to FLAT.

Step 3: usage charge with formula pricing (price_fx)

FORMULA pricing uses a Jakarta EL expression instead of a fixed number. The formula's context includes quantity (the EDR/usage quantity being rated).

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_CHG_FORMULA",
"description":"Quickstart formula-priced usage fee",
"type":"USAGE",
"invoiceSubCategory":"ISCAT_DEFAULT",
"taxClass":"NORMAL",
"inputUnitOfMeasure":"UOM_MERCHANT_TXN",
"ratingUnitOfMeasure":"UOM_MERCHANT_TXN",
"filterParam1":"QS1FORMULA"
}' \
http://localhost:8080/api/v1/charges
curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"pricingModel":"FORMULA",
"currency":"EUR",
"validFrom":"2026-01-01",
"price_fx":"#{quantity * 0.10}"
}' \
http://localhost:8080/api/v1/charges/QS1_CHG_FORMULA/pricing/draft

Response (201):

{"pricingVersions":[{"version":1,"pricingModel":"FORMULA","status":"DRAFT","price":0E-12,"price_fx":"#{quantity * 0.10}","currency":"EUR"}]}

price stays 0 (a placeholder); the real number comes from evaluating price_fx at rating time. Publish it the same way:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
http://localhost:8080/api/v1/charges/QS1_CHG_FORMULA/pricing/publish

Both MATRIX and FORMULA publish through the exact same one-mutable-draft lifecycle as FLAT (PUT .../pricing/draft then POST .../pricing/publish) — the pricing model only changes the shape of the draft body, not the lifecycle.

Step 4: order-time matrix rating

To make a matrix charge price at order time, the dimension attribute has to be linked to the product version, and that link can only be added while the version is DRAFT. Attempting it on an already-PUBLISHED version returns a clear 400:

{"status":"FAIL","message":"Cannot modify attributes on non-DRAFT version. Current status: PUBLISHED"}

Create a fresh DRAFT version, link the attribute, and publish it:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"shortDescription":"Quickstart Demo Product v2"}' \
http://localhost:8080/api/v1/products/QS1_PRODUCT/versions

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
http://localhost:8080/api/v1/products/QS1_PRODUCT/versions/2/attributes/QS1_ATTR_PLAN

curl -X PUT -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/products/QS1_PRODUCT/versions/2/status?status=PUBLISHED"

Publishing version 2 auto-closes the open-ended version 1 at version 2's start date, the same rule as a charge pricing publish: the version timeline stays contiguous (end-exclusive) and no two PUBLISHED versions overlap. If the predecessor cannot be closed cleanly (the new version has no start date, or starts at or before the predecessor's own start), the publish returns 409 instead of leaving an ambiguous overlap.

Then place the order, selecting PREMIUM through the product-level orderAttributes (live-verified, 201):

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_ORDER_MX",
"externalReference":"QS1_ORDER_REF_MX_001",
"orderType":"NEW",
"sellerCode":"QS1_SELLER",
"billingAccountCode":"CUST_SARA",
"userAccountCode":"CUST_SARA",
"orderOffers":[
{"subscriptionCode":"QS1_SUB_MX","orderLineType":"CREATE","offerTemplateCode":"QS1_OFFER",
"orderProducts":[
{"productCode":"QS1_PRODUCT","productActionType":"CREATE","quantity":1,
"orderAttributes":[{"attributeCode":"QS1_ATTR_PLAN","stringValue":"PREMIUM"}]}
]}
]
}' \
"http://localhost:8080/api/v1/orders?autoValidate=true"

Response (201): status: VALIDATED, subscription ACTIVE. The PREMIUM matrix row resolves to 35 EUR from the order attribute (see the preview in step 9 for the rated line). The one-shot charge rates immediately; the recurring charge's first wallet operation is produced at the end of its first calendar period (end-of-period application) by the recurring rating job.

You do not have to pass productVersion on the order product: when it is omitted, the order resolves the latest PUBLISHED version valid at the subscription date, and the same version is used consistently for attribute validation and for service instantiation.

fixed defect

An earlier run of this quickstart hit a reproducible 500 here (PriceELErrorException: no price for price plan version ...). Root cause: with two overlapping PUBLISHED product versions (version 1 without the attribute, version 2 with it), version resolution returned the oldest match, so the order validated attributes against version 2 but instantiated the service against version 1, silently dropping PLAN=PREMIUM; rating then found no matrix row and no default line. Fixed on feature/v1-full-api by three changes: version resolution picks the latest matching PUBLISHED version, service instantiation reuses the exact version the order was validated against, and publishing a product version auto-closes the open-ended predecessor so the overlap cannot arise in the first place. The calls above are live-verified against the fixed build.

Step 5: create a discount plan and its item

Create the discount plan directly:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_DISC_PLAN",
"description":"Quickstart discount plan",
"discountPlanType":"OFFER"
}' \
http://localhost:8080/api/v1/discountPlans

Response (201):

{"id":217,"code":"QS1_DISC_PLAN","description":"Quickstart discount plan","discountPlanType":"OFFER","status":"DRAFT","statusDate":"2026-07-09T13:19:42Z","initialQuantity":0,"usedQuantity":0,"applicationLimit":0,"automaticApplication":false,"sequence":217,"auditable":{...}}
fixed defect

An earlier run of this quickstart hit a reproducible 500 (NullPointerException, DiscountPlan.getId() ... "entity" is null) on this exact call. Root cause: DiscountPlanV1ResourceImpl.create re-read the plan with findByCode right after discountPlanApi.create(...), and under the persistence unit's org.hibernate.flushMode=COMMIT that query cannot see the not-yet-committed insert, so it returned null. Fixed on feature/v1-full-api by building the response from the entity the legacy API returns (the same pattern the seller v1 resource uses). The upsert form (POST /api/v1/discountPlans/{code}) had the same defect on its create branch and is fixed too. Both calls above are live-verified against the fixed build.

A new plan starts in DRAFT and activates with PUT /api/v1/discountPlans/{code}/activate once it has at least one item. The order flow in steps 6 to 8 was recorded against the pre-existing ACTIVE discount plan DP_TEST_A2 (discountPlanType: OFFER), which carries the item created next:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_DISC_ITEM_10PCT",
"description":"Quickstart 10 percent off",
"discountPlanItemType":"PERCENTAGE",
"discountValue": 10
}' \
http://localhost:8080/api/v1/discountPlans/DP_TEST_A2/items

A first attempt at placing an order referencing this discount plan (before the item had an accountingArticle) returned a clean, actionable 400:

{"status":"BAD_REQUEST","errorCode":"VALIDATE_FAILED","message":"discount plan item QS1_DISC_ITEM_10PCT has no accounting article  "}

Charges get an auto-created accounting article by default (GET /api/v1/charges/{code}/articles), so the fix is to point the discount item at that article:

curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"discountPlanItemType":"PERCENTAGE",
"discountValue": 10,
"accountingArticle":"QS1_CHG_SETUP"
}' \
http://localhost:8080/api/v1/discountPlans/DP_TEST_A2/items/QS1_DISC_ITEM_10PCT

Offers do not have a per-link discount-plan endpoint (unlike products, which have POST /{code}/discountPlans/{dpCode}) — link it via the batch discountPlans array on offer create or update:

curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"discountPlans":["DP_TEST_A2"]}' \
http://localhost:8080/api/v1/offerTemplates/QS1_OFFER_DISC

Step 7: place an order carrying the discount plan

To isolate the discount effect from the matrix pricing in step 4, this order targets a second, simpler product (QS1_PRODUCT_DISC) that only has the FLAT-priced one-shot charge attached, linked through a dedicated offer (QS1_OFFER_DISC) with DP_TEST_A2 attached.

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_ORDER_DISC",
"externalReference":"QS1_ORDER_REF_DISC_001",
"orderType":"NEW",
"sellerCode":"QS1_SELLER",
"billingAccountCode":"CUST_SARA",
"userAccountCode":"CUST_SARA",
"discountPlanCodes":["DP_TEST_A2"],
"orderOffers":[
{
"subscriptionCode":"QS1_SUB_DISC",
"orderLineType":"CREATE",
"offerTemplateCode":"QS1_OFFER_DISC",
"orderProducts":[
{"productCode":"QS1_PRODUCT_DISC","productActionType":"CREATE","quantity":1}
]
}
]
}' \
"http://localhost:8080/api/v1/orders?autoValidate=true"

Response (201): status: VALIDATED, subscription ACTIVE, one-shot charge instance already CLOSED — same shape as the plain order in the catalog-to-first-invoice quickstart.

Step 8: see the discount's price effect

curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"subscription.code":"QS1_SUB_DISC"}' \
--data-urlencode 'fields=id,code,status,amountWithoutTax,amountWithTax,discountPlan' \
http://localhost:8080/api/v1/query/walletOperation

Response:

{"total":2,"limit":100,"offset":0,"data":[
{"id":190006,"code":"QS1_CHG_SETUP","amountWithoutTax":49.000000000000,"amountWithTax":58.800000000000,"status":"OPEN"},
{"id":190007,"code":"QS1_CHG_SETUP","amountWithoutTax":-4.900000000000,"amountWithTax":-5.880000000000,"status":"OPEN","discountPlan":{"id":212}}
]}

Two wallet operations: the original charge (49.00 / 58.80) and a second, discountPlan-tagged wallet operation at -4.90 / -5.88 — exactly 10% of 49.00, negative, applying the discount as its own line rather than adjusting the original charge's amount in place. This is the mechanism to document for "how does a discount show up": a same-subscription negative WO referencing the DiscountPlan, not a modified original-charge amount.

Step 9: what the order preview shows

Running the step 7 order payload through POST /api/v1/orders/preview instead of a real create (live-verified):

{"currency":"MAD","offers":[{"subscriptionCode":"QS1_SUB_DISC","offerCode":"QS1_OFFER_DISC","lines":[
{"productCode":"QS1_PRODUCT_DISC","chargeCode":"QS1_CHG_SETUP","chargeType":"ONE_SHOT_SUBSCRIPTION","label":"Quickstart setup fee","amountWithoutTax":49.0,"amountWithTax":58.8,"overridden":false,"discountPlanCode":null},
{"productCode":"QS1_PRODUCT_DISC","chargeCode":"QS1_CHG_SETUP","chargeType":"ONE_SHOT_SUBSCRIPTION","label":"Quickstart setup fee","amountWithoutTax":-4.9,"amountWithTax":-5.88,"overridden":false,"discountPlanCode":"DP_TEST_A2"}
],"totalOneTime":44.1,"totalRecurring":0,"totalTax":8.82,"totalWithTax":52.92}],"chargeItems":[],"totalOneTime":44.1,"totalRecurring":0,"totalTax":8.82,"totalWithTax":52.92}

The preview applies discount plans exactly like the real create: the same negative wallet-operation shape from step 8 appears as its own line, marked with discountPlanCode, and the totals are net of the discount (44.10 / 52.92 = step 8's two WOs summed).

The preview also rates matrix lines. The step 4 order payload, run through POST /api/v1/orders/preview (live-verified against the fixed build):

{"currency":"MAD","offers":[{"subscriptionCode":"QS1_SUB_MX","offerCode":"QS1_OFFER","lines":[
{"productCode":"QS1_PRODUCT","chargeCode":"QS1_CHG_MATRIX","chargeType":"RECURRING","label":"Quickstart matrix-priced recurring fee","amountWithoutTax":35.0,"amountWithTax":42.0,"overridden":false,"servicePeriodStart":1783604644032,"servicePeriodEnd":1785542400000},
{"productCode":"QS1_PRODUCT","chargeCode":"QS1_CHG_SETUP","chargeType":"ONE_SHOT_SUBSCRIPTION","label":"Quickstart setup fee","amountWithoutTax":49.0,"amountWithTax":58.8,"overridden":false},
{"productCode":"QS1_PRODUCT","chargeCode":"QS1_CHG_FORMULA","chargeType":"USAGE","label":"Quickstart formula-priced usage fee","amountWithoutTax":null,"amountWithTax":null,"overridden":false}
]}],"chargeItems":[]}

The exact preview amount contract, as verified live:

  • FLAT and MATRIX lines carry real engine-rated amounts (the MATRIX line resolves its row from the orderAttributes selection, here PREMIUM at 35.00 / 42.00, with its first service period).
  • USAGE lines always carry null amounts. This is by design: there is no usage volume to rate at order time. Rate usage through mediation (see the usage mediation quickstart).
  • When any line in the response has null amounts, the offer-level and order-level totals are omitted rather than reported as a misleading partial sum.
  • Discount plans from every selection level (discountPlanCodes on the order, the offer, and each product) are applied virtually with the same validations as a real instantiation (plan must be ACTIVE/IN_USE, an OFFER-scope plan must be linked to the offer). Each discount emits its own negative line tagged discountPlanCode. The offer template's automatic-application plans apply without being listed.

Discount plan types in the preview, all live-verified together

Three plans of different types on one preview (10% OFFER-scope, 20% PRODUCT-scope, 5 EUR FIXED OFFER-scope):

{"lines":[
{"chargeCode":"QS1_CHG_SETUP","amountWithoutTax":49.0,"discountPlanCode":null},
{"chargeCode":"QS1_CHG_SETUP","amountWithoutTax":-4.9,"discountPlanCode":"DP_TEST_A2"},
{"chargeCode":"QS1_CHG_SETUP","amountWithoutTax":-8.82,"discountPlanCode":"QS1_DISC_PROD"},
{"chargeCode":"QS1_CHG_SETUP","amountWithoutTax":-5.0,"discountPlanCode":"QS1_DISC_FIXED"}
],"totalOneTime":30.28,"totalWithTax":36.34}

Note the cascading: the PRODUCT-scope 20% applies to the already-discounted base (20% of 44.10 = 8.82, not 9.80). A real order with the same three plans, one per selection level, bills the identical four wallet operations (live-verified: 49.00, -4.90, -8.82, -5.00).

every listed discount plan rates at order time on CREATE

Every code in a discountPlanCodes array is instantiated on the resulting subscription (order and offer level) or service instance (product level) BEFORE activation rating, so a create bills exactly what the preview shows, including several plans at the same level. Live-verified: an order carrying two order-level plans plus one product-level plan bills the same four wallet operations as its preview (49.00, -4.90, -8.82, -5.00). The selection is persisted with the order, so a DRAFT order (autoValidate=false) validated later through POST /api/v1/orders/{code}/validation bills the full selection too. Earlier builds instantiated only the first code per level before rating (the rest were attached post-validation, too late for one-shot charges); that migration debt (multi-discount-at-rating) is resolved.

Step 10: publish/lifecycle recap across all three pricing models

Pricing modelDraft body shapeRated at order time (live-verified)
FLAT{"pricingModel":"FLAT","price":49.00,"currency":"EUR"}Correct: 49.00 / 58.80
MATRIX{"pricingModel":"MATRIX","columns":[...],"rows":[...]}Correct: PREMIUM resolves to 35.00 / 42.00 (order 201 + preview line, step 4/9; an earlier defect here is fixed, see the step 4 callout)
FORMULA{"pricingModel":"FORMULA","price_fx":"#{quantity * 0.10}"}Usage charges rate through mediation, not at order time; verified via CDR rating in the usage mediation quickstart (0.04 EUR/unit formula-equivalent case, not this exact formula)

All three share the identical publish lifecycle (PUT .../pricing/draft -> POST .../pricing/publish), which is the main point: the pricing model only changes the draft body's shape, never the lifecycle mechanics.

Step 11: cleanup note

Every entity in this quickstart is QS1_-prefixed (QS1_SELLER, QS1_CHG_SETUP, QS1_CHG_MATRIX, QS1_CHG_FORMULA, QS1_PRODUCT, QS1_PRODUCT_DISC, QS1_OFFER, QS1_OFFER_DISC, QS1_ATTR_PLAN, QS1_DISC_PLAN) so it is easy to find and remove from a shared or demo environment. The one exception is the discount plan item (QS1_DISC_ITEM_10PCT), which was added to the pre-existing DP_TEST_A2 test discount plan used by the recorded order flow rather than to a new QS1_-prefixed one.

What this proved, end to end

ConceptVerified
Pricing lifecycle is model-agnosticFLAT/MATRIX/FORMULA all use the same draft/publish facade
Matrix columns auto-echo attribute metadataallowedValues populated from the linked Attribute automatically
Recurring charges require a calendar400 with a clear message if billingCalendar is missing
Product version attribute edits are DRAFT-only400 on a PUBLISHED version, works on a fresh DRAFT one
Discount plans apply as a separate negative WOnot an in-place adjustment of the original charge's amount
Discount plan items need an accounting article400 VALIDATE_FAILED with an actionable message otherwise
Matrix rating at order timeorder attribute PLAN=PREMIUM resolves the 35 EUR row on create and in preview (after the step 4 fix)
Product version publish auto-closes its predecessorcontiguous end-exclusive validity, 409 when the close would be invalid
Order preview contract (live-verified)flat and matrix lines carry engine-rated amounts; usage lines are null by design; discount plans of all types (percentage, fixed, OFFER/PRODUCT scope) apply as tagged negative lines with net totals (step 9)
Discount preview matches createthe same three plans, one per level, bill the identical wallet operations on a real order (49.00, -4.90, -8.82, -5.00)
One plan per level rates at order time on createadditional same-level codes attach post-validation and miss already-rated charges (step 9 caution)