Quickstart: catalog to first invoice
This walks the full quote-to-cash chain on v1: seller, priced charge, product,
offer, order (which creates and activates a subscription), rating, a bill
run, and the resulting invoice (including its XML). Every step below ran
against a live local Billerang stack (http://localhost:8080) and the
response shown is the real captured response, not a hand-written example.
Entity codes are prefixed QS1_ so they are easy to find and delete
afterward; no existing demo data was modified (the billing account used to
receive the order is pre-existing demo data, read/used but not changed in
shape).
Step count: 12 steps, all run live in this session.
Prerequisites
A bearer token, per Authentication:
export TOKEN=$(curl -s -X POST "$KEYCLOAK_TOKEN_URL" \
-d "grant_type=client_credentials&client_id=$KEYCLOAK_CLIENT_ID&client_secret=$KEYCLOAK_CLIENT_SECRET" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
The client-credentials token expires in 300 seconds (expires_in: 300,
verified live). If a step below returns a login-page redirect or 401
instead of JSON, refresh the token and retry — this is expected on a long
walkthrough, not a bug.
Step 1: create a seller
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_SELLER",
"description":"Quickstart Demo Seller",
"tradingCountryCode":"FR",
"tradingCurrencyCode":"EUR",
"tradingLanguageCode":"ENG"
}' \
http://localhost:8080/api/v1/sellers
Response (201):
{"id":450,"auditable":{"created":"2026-07-09T12:34:11Z","creator":"service-account-billerang-backend"},"code":"QS1_SELLER","description":"Quickstart Demo Seller","tradingCountryCode":"FR","tradingCurrencyCode":"EUR","tradingLanguageCode":"ENG"}
Step 2: create a charge (ONESHOT, DRAFT)
Charges need a real invoiceSubCategory and taxClass code. Look them up
first with the Query API:
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/query/billingDocumentSubCategory?limit=5&fields=code,description"
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/query/taxClass?limit=5&fields=code,description"
This stack has ISCAT_DEFAULT and NORMAL (a standard-rate tax class)
available, so:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_CHG_SETUP",
"description":"Quickstart setup fee",
"type":"ONESHOT",
"oneShotType":"SUBSCRIPTION",
"invoiceSubCategory":"ISCAT_DEFAULT",
"taxClass":"NORMAL"
}' \
http://localhost:8080/api/v1/charges
Response (201):
{"id":243,"code":"QS1_CHG_SETUP","description":"Quickstart setup fee","status":"DRAFT","invoiceSubCategory":"ISCAT_DEFAULT","taxClass":"NORMAL","roundingMode":"NEAREST","billSeparately":false,"oneShotType":"SUBSCRIPTION","immediateInvoicing":false,"auditable":{"created":"2026-07-09T12:34:27Z","creator":"service-account-billerang-backend"},"chargeType":"ONESHOT"}
A new charge starts DRAFT with no pricing. oneShotType: SUBSCRIPTION means
this charge fires once, at subscription creation.
Step 3: add a flat price and publish it
The charge pricing draft/publish facade (PUT .../pricing/draft then
POST .../pricing/publish) is the one-mutable-draft lifecycle from the
pricing-version lifecycle work: callers
work with THE draft, never a version number.
curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"pricingModel":"FLAT",
"price": 49.00,
"currency":"EUR",
"validFrom":"2026-01-01"
}' \
http://localhost:8080/api/v1/charges/QS1_CHG_SETUP/pricing/draft
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
http://localhost:8080/api/v1/charges/QS1_CHG_SETUP/pricing/publish
Response after publish (200):
{"id":243,"code":"QS1_CHG_SETUP","description":"Quickstart setup fee","status":"ACTIVE","invoiceSubCategory":"ISCAT_DEFAULT","taxClass":"NORMAL","roundingMode":"NEAREST","billSeparately":false,"pricingVersions":[{"version":1,"pricingModel":"FLAT","status":"PUBLISHED","price":49.000000000000,"validFrom":"2026-01-01T00:00:00Z","currency":"EUR","priority":0}],"oneShotType":"SUBSCRIPTION","immediateInvoicing":false,"auditable":{...},"chargeType":"ONESHOT"}
Publishing the pricing draft flips the charge's own status from DRAFT to
ACTIVE in the same call — a separate POST /{code}/activate afterward is a
no-op once a pricing version is published (verified: calling it here
returned the identical body, unchanged).
Step 4: create a product and attach the charge
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_PRODUCT",
"label":"Quickstart Demo Product",
"description":"Quickstart Demo Product"
}' \
http://localhost:8080/api/v1/products
Response (201): a product is created with a DRAFT currentVersion
(version 1) automatically — you do not create the first version separately.
{"id":115,"code":"QS1_PRODUCT","label":"Quickstart Demo Product","status":"DRAFT","currentVersion":{"version":1,"status":"DRAFT","validFrom":"2026-07-09T12:34:59Z"},"charges":[],"auditable":{...}}
Attach the charge (reference-only link, the charge template itself is untouched):
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"chargeCode":"QS1_CHG_SETUP"}' \
http://localhost:8080/api/v1/products/QS1_PRODUCT/charges
Response (201) echoes the product with charges: [{"code":"QS1_CHG_SETUP", "status":"ACTIVE", ...}].
Step 5: publish the product version, activate the product
curl -X PUT -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/products/QS1_PRODUCT/versions/1/status?status=PUBLISHED"
curl -X PUT -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/products/QS1_PRODUCT/status?status=ACTIVE"
Response after both (200):
{"id":115,"code":"QS1_PRODUCT","status":"ACTIVE","currentVersion":{"version":1,"status":"PUBLISHED",...},"charges":[{"code":"QS1_CHG_SETUP","status":"ACTIVE",...}],"auditable":{...}}
Two independent status axes: the product-version lifecycle (DRAFT →
PUBLISHED) and the product lifecycle (DRAFT → ACTIVE). Both need to be
flipped before the product can go on an offer that a real order will use.
Step 6: create the offer, referencing the product and seller
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_OFFER",
"name":"Quickstart Demo Offer",
"description":"Quickstart Demo Offer",
"sellers":["QS1_SELLER"],
"products":[{"product":"QS1_PRODUCT","mandatory":true}]
}' \
http://localhost:8080/api/v1/offerTemplates
Response (201): offers start in IN_DESIGN.
{"id":343,"code":"QS1_OFFER","status":"IN_DESIGN","sellers":["QS1_SELLER"],"products":[{"product":"QS1_PRODUCT","sequence":0,"mandatory":true,"status":"ACTIVE","currentProductVersion":1}],"generateQuoteEdrPerProduct":false,"auditable":{...}}
Activate it:
curl -X PUT -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/offerTemplates/QS1_OFFER/status?status=ACTIVE"
Response (200): "status":"ACTIVE", rest unchanged.
Step 7: place an order (creates and activates the subscription)
This reuses an existing demo billing account (CUST_SARA) instead of
building a fresh customer/customer-account/billing-account/user-account
hierarchy, since that hierarchy has no v1 resource yet (it is legacy-API
territory — see the migration table in the
introduction). No data on CUST_SARA is modified by
this step beyond adding the new order/subscription.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"code":"QS1_ORDER",
"externalReference":"QS1_ORDER_REF_001",
"orderType":"NEW",
"sellerCode":"QS1_SELLER",
"billingAccountCode":"CUST_SARA",
"userAccountCode":"CUST_SARA",
"orderOffers":[
{
"subscriptionCode":"QS1_SUB",
"orderLineType":"CREATE",
"offerTemplateCode":"QS1_OFFER",
"orderProducts":[
{"productCode":"QS1_PRODUCT","productActionType":"CREATE","quantity":1}
]
}
]
}' \
"http://localhost:8080/api/v1/orders?autoValidate=true"
orderProducts is required even for an offer whose only product is
mandatory: a first attempt without it returned 400 ORDER_OFFER_CREATE_FAILED ("orderProducts" cannot be empty") — captured
verbatim, not invented.
Response (201):
{"code":"QS1_ORDER","externalReference":"QS1_ORDER_REF_001","orderNumber":"ORDER_000000168","orderType":"NEW","status":"VALIDATED","sellerCode":"QS1_SELLER","billingAccount":{"code":"CUST_SARA","description":"Sara Benali"},"customer":{"code":"CUST_SARA","description":"Sara Benali"},"userAccount":{"code":"CUST_SARA","description":"Sara Benali"},"orderOffers":[{"subscriptionCode":"QS1_SUB","orderLineType":"CREATE","offerTemplateCode":"QS1_OFFER","subscription":{"code":"QS1_SUB","status":"ACTIVE","subscriptionDate":1783600560068},"serviceInstances":[{"code":"QS1_PRODUCT","status":"ACTIVE","productCode":"QS1_PRODUCT","productVersion":1,"quantity":1.000000000000,"chargeInstances":[{"code":"QS1_CHG_SETUP","chargeCode":"QS1_CHG_SETUP","status":"CLOSED"}]}]}],"orderChargeItems":[],"auditable":{}}
autoValidate=true (the default) means the order is VALIDATED
synchronously and the subscription is already ACTIVE in this same
response — no separate activation call needed for a straightforward NEW
order. The one-shot charge instance already shows status: CLOSED (it
already fired once, at subscription creation).
Step 8: check the wallet operation the charge produced
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"code":"QS1_CHG_SETUP"}' \
--data-urlencode 'fields=id,code,status,amountWithoutTax,amountWithTax,billingAccount' \
http://localhost:8080/api/v1/query/walletOperation
Response:
{"total":1,"limit":100,"offset":0,"data":[{"id":190002,"code":"QS1_CHG_SETUP","amountWithoutTax":49.000000000000,"amountWithTax":58.800000000000,"billingAccount":{"id":79},"status":"OPEN"}]}
49.00 without tax, 58.80 with tax (NORMAL tax class applies 20%),
matching the published price exactly. Status OPEN: the wallet operation
exists but has not yet been converted into a RatedTransaction.
Step 9: rate the wallet operation (RT_Job)
A billing run only counts RatedTransaction rows, not WalletOperation
rows directly (RatedTransactionService.isBillingAccountBillable runs the
named query RatedTransaction.countNotInvoicedOpenByBA). Converting WO to
RT is a job, executed through the job-instance execution convention
documented in the introduction:
POST /{code}/executions.
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/api/v1/query/jobInstance?filters=%7B%22jobTemplate%22%3A%22RatedTransactionsJob%22%7D&fields=code,jobTemplate"
returns RT_Job (jobTemplate: RatedTransactionsJob) as the pre-configured
instance on this stack. Execute it:
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
http://localhost:8080/api/v1/jobInstances/RT_Job/executions
Response (202):
{"executionId":1948,"jobInstanceCode":"RT_Job","status":"RUNNING"}
Job executions are async; poll or wait a few seconds, then re-check the wallet operation:
{"total":1,"limit":100,"offset":0,"data":[{"id":190002,"code":"QS1_CHG_SETUP","ratedTransaction":{"id":145002},"status":"TREATED"}]}
status: TREATED and a ratedTransaction reference confirm the WO is now
billable.
Step 10: run the bill run
processType and runOptions.autoValidate must be compatible: with
processType=MANUAL, autoValidate=true is rejected outright (a clear
400 explaining why, captured verbatim):
{"error":"INVALID_AUTO_VALIDATE_COMBO","message":"runOptions.autoValidate=true is only valid with processType=FULL_AUTOMATIC. With processType=MANUAL the engine stops before VALIDATED and the sync POST would never return. Either set processType=FULL_AUTOMATIC, set autoValidate=false, or call POST /api/v1/billingRuns/async."}
invoiceDate and lastTransactionDate are both required (each missing one
returned a matching 400: MISSING_INVOICE_DATE, then
MISSING_LAST_TX_DATE, captured verbatim in this session). Also: a plain
calendar date is interpreted as midnight UTC of that day, so a
lastTransactionDate on the same calendar day as the order (created mid-day)
excludes it — use the following day, or a later time on the same day, to be
safe. A first attempt with lastTransactionDate: "2026-07-09" (same day as
the order) returned 422 NO_BILLABLE_TRANSACTIONS for exactly this reason;
retrying with "2026-07-10" succeeded.
curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"billingAccountCodes":["CUST_SARA"],
"processType":"FULL_AUTOMATIC",
"description":"QS1 quickstart bill run",
"invoiceDate":"2026-07-10",
"lastTransactionDate":"2026-07-10",
"runOptions": {"autoValidate": true}
}' \
http://localhost:8080/api/v1/billingRuns
Response (200):
{"id":94,"code":"BR_1783600716573_7e6f97d0","status":"VALIDATED","processType":"FULL_AUTOMATIC","totals":{"billableBillingAccountNumber":1,"invoiceNumber":1,"amountWithoutTax":49.000000000000,"amountWithTax":58.800000000000,"amountTax":9.800000000000},"jobExecutions":[{"jobRunId":1950,"status":"COMPLETED","nbItemsToProcess":2,"nbItemsCorrectlyProcessed":2,"nbItemsProcessedWithWarning":0,"nbItemsProcessedWithError":0},{"jobRunId":1949,"status":"COMPLETED","nbItemsToProcess":1,"nbItemsCorrectlyProcessed":1,"nbItemsProcessedWithWarning":0,"nbItemsProcessedWithError":0}]}
One invoice, totals matching the published price exactly (49.00 / 58.80 / 9.80 tax).
Step 11: fetch the invoice
curl -G -H "Authorization: Bearer $TOKEN" \
--data-urlencode 'filters={"billingRun.id":94}' \
--data-urlencode 'fields=id,invoiceNumber,amountWithoutTax,amountWithTax,status,invoiceDate' \
http://localhost:8080/api/v1/query/billingDocument
Response:
{"total":1,"limit":100,"offset":0,"data":[{"id":113,"invoiceNumber":"000000169","invoiceDate":1783641600000,"status":"VALIDATED","amountWithoutTax":49.000000000000,"amountWithTax":58.800000000000}]}
Step 12: retrieve the invoice XML; trigger the PDF job
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/billingDocuments/113/xml
Response (200, application/xml, 9976 bytes captured): the XML
includes the header (invoice id="113" ... currency="MAD" ...) and the
matching line amounts:
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<invoice adjustedInvoiceNumber="" ... country="MA" currency="MAD" customerAccountCode="CUST_SARA" customerId="CUST_SARA" id="113" invoiceCodeType="380" ...>
with amountWithoutTax="49.000000000000" and amountWithTax="58.800000000000"
repeated at header and line level.
The XML's currency="MAD" label does not match the EUR currency set on the
charge's published price, even though the numeric amounts (49.00 / 58.80)
are unconverted. This looks like the invoice XML is echoing the billing
account/customer's own transactional currency label rather than a real FX
conversion of the priced amount. Confirm with the API Architect whether this
is expected multi-currency behavior (label mismatch only, amounts stay in
the charge's pricing currency) or a defect, before relying on the XML's
currency attribute for a multi-currency deployment.
PDF generation is triggered through the billing run, not a direct invoice
endpoint (there is no GET /api/v1/billingDocuments/{id}/pdf in the current
InvoiceV1Resource — only /xml):
curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/billingRuns/94/pdf
Response (202):
{"billingRunId":94,"billingRunCode":"BR_1783600716573_7e6f97d0","jobRunId":1951,"jobInstanceCode":"PDF_Job","status":"ACCEPTED","pollUrl":"/api/v1/billingRuns/94"}
Async, same job-execution pattern as RT_Job: poll GET /api/v1/billingRuns/{id} (or the invoice record itself) until the PDF job
completes.
What this proved, end to end
| Concept | Verified |
|---|---|
| Charge pricing draft/publish lifecycle | flips charge status to ACTIVE automatically |
| Product version vs. product status | two independent axes, both must be flipped |
Order orderProducts requirement | mandatory even for a single-product offer |
| One-shot charge firing | happens at order validation, produces an OPEN WO immediately |
| WO → RT conversion | a separate job (RT_Job / RatedTransactionsJob), not automatic |
| Bill run date semantics | plain dates = midnight UTC; lastTransactionDate must be after the WO's timestamp |
autoValidate + processType coupling | autoValidate=true requires FULL_AUTOMATIC |
| Invoice totals | exactly match the published charge price and tax class |