Skip to main content

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 (DRAFTPUBLISHED) and the product lifecycle (DRAFTACTIVE). 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.

TODO-VERIFY

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

ConceptVerified
Charge pricing draft/publish lifecycleflips charge status to ACTIVE automatically
Product version vs. product statustwo independent axes, both must be flipped
Order orderProducts requirementmandatory even for a single-product offer
One-shot charge firinghappens at order validation, produces an OPEN WO immediately
WO → RT conversiona separate job (RT_Job / RatedTransactionsJob), not automatic
Bill run date semanticsplain dates = midnight UTC; lastTransactionDate must be after the WO's timestamp
autoValidate + processType couplingautoValidate=true requires FULL_AUTOMATIC
Invoice totalsexactly match the published charge price and tax class