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.
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":{...}}
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
Step 6: link the discount plan to an offer
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:
FLATandMATRIXlines carry real engine-rated amounts (theMATRIXline resolves its row from theorderAttributesselection, herePREMIUMat 35.00 / 42.00, with its first service period).USAGElines always carrynullamounts. 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
nullamounts, the offer-level and order-level totals are omitted rather than reported as a misleading partial sum. - Discount plans from every selection level (
discountPlanCodeson 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 taggeddiscountPlanCode. 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 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 model | Draft body shape | Rated 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
| Concept | Verified |
|---|---|
| Pricing lifecycle is model-agnostic | FLAT/MATRIX/FORMULA all use the same draft/publish facade |
| Matrix columns auto-echo attribute metadata | allowedValues populated from the linked Attribute automatically |
| Recurring charges require a calendar | 400 with a clear message if billingCalendar is missing |
| Product version attribute edits are DRAFT-only | 400 on a PUBLISHED version, works on a fresh DRAFT one |
| Discount plans apply as a separate negative WO | not an in-place adjustment of the original charge's amount |
| Discount plan items need an accounting article | 400 VALIDATE_FAILED with an actionable message otherwise |
| Matrix rating at order time | order attribute PLAN=PREMIUM resolves the 35 EUR row on create and in preview (after the step 4 fix) |
| Product version publish auto-closes its predecessor | contiguous 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 create | the 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 create | additional same-level codes attach post-validation and miss already-rated charges (step 9 caution) |