Discount a customer group
In this lesson you register three projects of the simulated cloud in the project registry, group them under a customer, put a 10% discount on each membership, run July 2026 again, and read one discounted statement and the group's rollup.
At the end you hold the project registry with its first four rows, a new run of July 2026 with its id in RUN_ID, and the JSON export of that run under ~/tally-tutorial/2026-07-group.
This lesson takes about 5 minutes.
Before you start
The state Watch the month in Grafana leaves, which is the state Meter and rate your first month leaves:
- the kind cluster
tallywith the dev overlay; - the compose stack with the simulator holding its 84 notifications, so
GET /clockonhttp://127.0.0.1:8091/clockanswersholdingtrue; - the reporting database holding the month of July 2026 minus the held share, with an empty project registry;
- the engine database holding the pricing model
2026-03and one completed, not finalized run of2026-07; - the JSON export of that run under
~/tally-tutorial/2026-07; tally-ca.crtat the repository root;- the VictoriaMetrics port-forward on
127.0.0.1:8428; - the seven variables
TALLY_REPORTING_DB_URL,TALLY_API_TOKEN(an admin token),TALLY_ENGINE_DB_URL,TALLY_ENGINE_REPORTING_DB_URL,TALLY_ENGINE_COUNTER_SOURCES,TALLY_ENGINE_VM_URLandRUN_IDin a shell at the repository root.
- the kind cluster
jqon the path, which themake check-toolsof lesson 1 called.
If you closed that shell, restore it with this block:
export TALLY_REPORTING_DB_URL='postgres://tally:tally-dev-password@db.tally.127-0-0-1.nip.io:5432/tally_reporting?sslmode=disable'
TALLY_API_TOKEN="$(go run ./cmd/tally-reporting-admin create-api-token --role admin --description 'tutorial')"
export TALLY_API_TOKEN
export TALLY_ENGINE_DB_URL='postgres://tally:tally-dev-password@db.tally.127-0-0-1.nip.io:5432/tally_engine?sslmode=disable'
export TALLY_ENGINE_REPORTING_DB_URL='postgres://tally_engine:tally-dev-password@db.tally.127-0-0-1.nip.io:5432/tally_reporting?sslmode=disable'
export TALLY_ENGINE_COUNTER_SOURCES=deploy/kubernetes/overlays/dev/counter-sources.yaml
export TALLY_ENGINE_VM_URL=http://127.0.0.1:8428
kubectl --context kind-tally -n tally port-forward svc/victoriametrics 8428:8428 &
make -s ca > tally-ca.crtA token is printed once, so a closed shell means a new token. The one you minted before stays valid until it is revoked, which takes the id on the created api_tokens <id> line the command printed beside it, so keep that line where you mean to revoke, the way issue and revoke credentials says. Every token this track mints goes with the cluster Tear down your local Tally removes. The port-forward line and the CA line are for a shell that lost them. RUN_ID is not needed before this lesson sets it anew.
A connection error from any go run command means the cluster is not up, and kind get clusters then prints nothing. curl: (7) on the API hostname means the same. Both are cured by Set up your local Tally.
Register the three classic projects
Register the first of the three tenants:
shcurl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"platform": "openstack", "cloud": "os-sim", "external_id": "018504a6cc10019a40e3f9eef4dae529", "name": "Acme team 1"}' \ https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects | jq .json{ "cloud": "os-sim", "created_at": "2026-09-07T21:26:32.361891Z", "external_id": "018504a6cc10019a40e3f9eef4dae529", "id": "8e4e84bd-e6fb-4c38-a2eb-cde386f94202", "metadata": {}, "name": "Acme team 1", "platform": "openstack" }-dmakes the call aPOST /api/v1/projects. The registry is keyed by cloud and external id, and the answer is the row as it is now registered.cloudos-sim,external_id,nameAcme team 1,platformopenstackandmetadata{}have to match.idandcreated_atare your own.The three classic projects of the month are named after their place in the customer group, because the simulated cloud carries no tenant names on the bus.
A 409 with
typeurn:tally:error:conflictand the detaila project with this cloud and external id is already registeredmeans this step ran before, and the read-back below reads the id it needs anyway. A 401 withtypeurn:tally:error:unauthorizedmeansTALLY_API_TOKENis empty in this shell, so mint one with the restore block above. A 403 withtypeurn:tally:error:forbiddenmeans the token is not an admin token.Register the other two:
shcurl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"platform": "openstack", "cloud": "os-sim", "external_id": "34e991db9fc6466f8ca69b43f70fce65", "name": "Acme team 2"}' \ https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects | jq -c '{id, external_id, name}' curl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d '{"platform": "openstack", "cloud": "os-sim", "external_id": "d5a8024946ddf673277b9e2490643a2c", "name": "Acme team 3"}' \ https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects | jq -c '{id, external_id, name}'json{"id":"1bb9b4d0-b256-4e78-bc0c-385787c23495","external_id":"34e991db9fc6466f8ca69b43f70fce65","name":"Acme team 2"} {"id":"7e35789b-36b1-4f90-b5fb-f924d79ebfa6","external_id":"d5a8024946ddf673277b9e2490643a2c","name":"Acme team 3"}external_idandnamehave to match on both lines, and the ids are your own. The same three refusals apply.Read the three ids back into the shell:
shACME_1_ID="$(curl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" 'https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects?cloud=os-sim&external_id=018504a6cc10019a40e3f9eef4dae529' | jq -r '.items[0].id')" ACME_2_ID="$(curl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" 'https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects?cloud=os-sim&external_id=34e991db9fc6466f8ca69b43f70fce65' | jq -r '.items[0].id')" ACME_3_ID="$(curl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" 'https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects?cloud=os-sim&external_id=d5a8024946ddf673277b9e2490643a2c' | jq -r '.items[0].id')" export ACME_1_ID ACME_2_ID ACME_3_ID echo "$ACME_1_ID $ACME_2_ID $ACME_3_ID"text8e4e84bd-e6fb-4c38-a2eb-cde386f94202 1bb9b4d0-b256-4e78-bc0c-385787c23495 7e35789b-36b1-4f90-b5fb-f924d79ebfa6The three ids are your own, and they are the ids the calls above printed. This read is what a reader who saw the 409 runs, because it reads what is registered whether this shell registered it or not. The registry answers
{"items": [...], "next_cursor": null}, and.items[0].idis the id of the one row the cloud and the external id name.An id printing
nullmeans that registration did not happen, soitemsis empty, and the cure is the registration above. A relation sent withnullin its path is answered 400 withtypeurn:tally:error:validationand one error located atpath.id, becausenullis not a uuid.
Create the customer group
Register the meta-project the three tenants become members of:
shACME_ID="$(go run ./cmd/tally-reporting-admin create-meta-project --external-id acme --name 'Acme')" export ACME_ID echo "$ACME_ID"textregistered meta-project acme 13a5e981-edc2-4666-b22e-65e7696de02bregistered meta-project acmeis the CLI's notice on stderr. The id went to stdout and into the variable, and it is your own.A meta-project is a
projectsrow with platform and cloud bothmeta. It owns no resource and exists to be the target ofmember_ofrelations: meta-projects and partners.Error: meta-project acme: already registeredwith exit status 1 means the row exists from an earlier run of this step, andGET /api/v1/projects?cloud=meta&external_id=acmepiped intojq -r '.items[0].id'reads its id the way the read-back above reads the tenants'.TALLY_REPORTING_DB_URLhas to be exported for the CLI to reach the registry, and adial tcperror means it is not, or the cluster is down.
Put the discount on the memberships
Relate the first tenant to the group:
shcurl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d @- "https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects/$ACME_1_ID/relations" <<EOF | jq . { "target_id": "$ACME_ID", "relation_type": "member_of", "valid_from": "2026-07-01T00:00:00Z", "metadata": { "pricing_adjustments": [ {"type": "project_discount", "rate": "0.10", "scope": "all", "description": "Acme group discount"} ] } } EOFjson{ "created_at": "2026-09-07T21:26:57.706276Z", "id": "8d9aaff5-56f2-43ba-8e04-6a73906136cf", "metadata": { "pricing_adjustments": [ { "description": "Acme group discount", "rate": "0.10", "scope": "all", "type": "project_discount" } ] }, "relation_type": "member_of", "source_id": "8e4e84bd-e6fb-4c38-a2eb-cde386f94202", "target_id": "13a5e981-edc2-4666-b22e-65e7696de02b", "valid_from": "2026-07-01T00:00:00Z", "valid_to": null }The body is sent as a heredoc so that
$ACME_IDexpands inside real JSON.<<EOF | jq .is one command: the heredoc feedscurland the pipe feedsjq.relation_typemember_of,valid_from2026-07-01T00:00:00Z,valid_tonull and the onepricing_adjustmentsentry have to match.id,source_id,target_idandcreated_atare your own.valid_fromis the first instant of July because the default is the instant the relation is written, which is now and not in July, and a relation applies to a period only where its validity overlaps it. The rate is a string,"0.10", never a number. The adjustments of a relation are fixed for its lifetime, and a change is a closed relation and a successor, as model a customer group with a discount shows.A 409 with
typeurn:tally:error:conflictand the detaila relation of this type between these projects is already activemeans this step ran before and nothing needs doing. A 400 withtypeurn:tally:error:validationwhose error is located atbody.target_idmeansACME_IDis empty in this shell. A 422 withtypeurn:tally:error:validation, the detailthe pricing adjustments of this relation do not match the adjustments schemaand an error atbody.metadata.pricing_adjustments.0.ratereadinggot number, want stringmeans the rate was sent as a number.Relate the other two:
shcurl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d @- "https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects/$ACME_2_ID/relations" <<EOF | jq -c '{id, source_id, relation_type}' { "target_id": "$ACME_ID", "relation_type": "member_of", "valid_from": "2026-07-01T00:00:00Z", "metadata": { "pricing_adjustments": [ {"type": "project_discount", "rate": "0.10", "scope": "all", "description": "Acme group discount"} ] } } EOF curl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" -H 'Content-Type: application/json' \ -d @- "https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects/$ACME_3_ID/relations" <<EOF | jq -c '{id, source_id, relation_type}' { "target_id": "$ACME_ID", "relation_type": "member_of", "valid_from": "2026-07-01T00:00:00Z", "metadata": { "pricing_adjustments": [ {"type": "project_discount", "rate": "0.10", "scope": "all", "description": "Acme group discount"} ] } } EOFjson{"id":"071e2e14-d251-4bd2-a229-3f15ec97eacf","source_id":"1bb9b4d0-b256-4e78-bc0c-385787c23495","relation_type":"member_of"} {"id":"14b925a3-08e4-4e27-ace8-5ebb3837747b","source_id":"7e35789b-36b1-4f90-b5fb-f924d79ebfa6","relation_type":"member_of"}relation_typehas to match on both lines. The ids are your own, andsource_idis the member each relation leaves.Count the memberships the group holds:
shcurl -s --cacert tally-ca.crt -H "Authorization: Bearer $TALLY_API_TOKEN" "https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects/$ACME_ID/relations?direction=incoming&at=2026-07-01T00:00:00Z" | jq '.items | length'text3The count has to be 3, the three memberships valid at the first instant of July.
direction=incominglists the relations that reach the meta-project, andatis the instant the answer describes.
Run the month again
Meter and rate July 2026 once more:
shgo run ./cmd/tally-engine run --period 2026-07textrun 3935cd7f-204e-411a-baee-c8fa395abdd9 completed for 2026-07 with pricing model 2026-03 metered 867 candidates into 940 usage records, 3081 rated records and 6 project statements applied 3 pricing adjustments superseded run 108a9c8a-8ea5-4788-a869-f3aa796ca76a warnings recorded in runs.stats: 38 metering, 0 counter, 0 attribution, 0 adjustment, 2 unpriced resource types, 0 unreadable fields, 3 unregistered projectsThe
meteredline,applied 3 pricing adjustmentsand the warnings line have to match. Both run ids are your own, and the superseded one is the run Meter and rate your first month made.The
meteredcounts are the ones that run printed, 867 candidates, 940 usage records, 3081 rated records and 6 statements, because nothing in the month changed. An adjustment is a record of its own kind beside the rated records, which is whatapplied 3counts, one line per member statement. The warnings line now ends in3 unregistered projects: the three registered ones left the list.A run before finalization supersedes the run before it in the same transaction, so the period never has two completed runs: runs before finalization.
Put the id from the first line of your own output in place of this one:
shexport RUN_ID=3935cd7f-204e-411a-baee-c8fa395abdd9Every command below reads
RUN_ID.Read what stands there instead if the run printed something else:
no pricing model is valid for this periodmeans the model Meter and rate your first month imported is missing. Import it and run the month again.- An error opening on
another run of this period is in progressmeans anothertally-engine runof this period is live, in this shell or in another one. Let that run end; the hourly tick is not it, because the tick leaves a month that already carries a completed run alone. - A non-zero
countercount on the warnings line means the port-forward died before the run read the store. Start it again and run the month again. reading the counter sources deploy/kubernetes/overlays/dev/counter-sources.yaml: open deploy/kubernetes/overlays/dev/counter-sources.yaml: no such file or directorymeans the shell is not at the repository root, orTALLY_ENGINE_COUNTER_SOURCESis not exported.
Read a discounted statement
Export the run with a rollup over
member_of:shgo run ./cmd/tally-engine export --run "$RUN_ID" --format json --out ~/tally-tutorial/2026-07-group --rollup member_oftextrun 3935cd7f-204e-411a-baee-c8fa395abdd9 exported for 2026-07 as json into /Users/berendt/tally-tutorial/2026-07-group wrote run.json and 6 statements wrote kickbacks.json with 0 kickbacks wrote 1 rollup documents over member_ofThe second, the third and the fourth line have to match, and
wrote 1 rollup documents over member_ofis the line--rollup member_ofadds. The run id and the home directory are your own. The export of Meter and rate your first month under~/tally-tutorial/2026-07stays as the list-price comparison, and a directory that already holds files is refused, which is why every export of this track gets a directory of its own.Read what the first Acme tenant owes:
shjq '{base_cost, adjustments, net_cost, total}' ~/tally-tutorial/2026-07-group/statement-os-sim%2F018504a6cc10019a40e3f9eef4dae529.jsonjson{ "base_cost": 876.63, "adjustments": [ { "type": "project_discount", "relation_type": "member_of", "relation_target": "acme", "relation_id": "8d9aaff5-56f2-43ba-8e04-6a73906136cf", "scope": "all", "description": "Acme group discount", "rate": 0.100000, "base": 876.63, "amount": -87.66 } ], "net_cost": 788.97, "total": 788.97 }base_cost876.63 is the total the same statement carried at list price. The oneadjustmentsline hastypeproject_discount,relation_typemember_of,relation_targetacme,scopeall,rate0.100000,base876.63 andamount-87.66, the base times the rate rounded once to two places.net_cost788.97 is the base plus the signed amount, andtotalis the net. All of these have to match.relation_idis your own, the id of the membership this line traces back to. The amount is rounded the way every other amount is: why rounding happens once.Read what a tenant outside the group owes:
shjq '{base_cost, total}' ~/tally-tutorial/2026-07-group/statement-os-sim%2F10e287d5788957a2a331cabd1b5dccdf.jsonjson{ "base_cost": null, "total": 698.29 }This is the CI tenant, which no membership reaches, so its statement carries none of the four members
base_cost,adjustments,net_costandkickback_total, andjqprints an absent member as null. Its total 698.29 has to match and is unchanged from the list-price run.
Read the rollup
List what the export wrote:
shls ~/tally-tutorial/2026-07-grouptextkickbacks.json rollup-meta%2Facme.json run.json statement-os-sim%2F005be5adeef3d87e280d03d9d57c38b4.json statement-os-sim%2F018504a6cc10019a40e3f9eef4dae529.json statement-os-sim%2F10e287d5788957a2a331cabd1b5dccdf.json statement-os-sim%2F34e991db9fc6466f8ca69b43f70fce65.json statement-os-sim%2Fd5a8024946ddf673277b9e2490643a2c.json statement-os-sim%2Fe31f9083a7e5ee15071a3bd53cb2bac7.jsonThe nine names have to match.
rollup-meta%2Facme.jsonstands besiderun.json,kickbacks.jsonand the six statements.Read the group's rollup document:
shjq . ~/tally-tutorial/2026-07-group/rollup-meta%2Facme.jsonjson{ "billing_period": { "from": "2026-07-01T00:00:00Z", "to": "2026-08-01T00:00:00Z" }, "project_id": "acme", "platform": "meta", "relation_type": "member_of", "kind": "regular", "corrects_run_id": null, "members": [ { "file": "statement-os-sim%2F018504a6cc10019a40e3f9eef4dae529.json", "cloud": "os-sim", "project_id": "018504a6cc10019a40e3f9eef4dae529", "total": 788.97, "currency": "EUR" }, { "file": "statement-os-sim%2F34e991db9fc6466f8ca69b43f70fce65.json", "cloud": "os-sim", "project_id": "34e991db9fc6466f8ca69b43f70fce65", "total": 586.47, "currency": "EUR" }, { "file": "statement-os-sim%2Fd5a8024946ddf673277b9e2490643a2c.json", "cloud": "os-sim", "project_id": "d5a8024946ddf673277b9e2490643a2c", "total": 814.59, "currency": "EUR" } ], "total": 2190.03, "currency": "EUR" }project_idacme,platformmeta,relation_typemember_of,kindregular, the threememberswith their files and their totals 788.97, 586.47 and 814.59, and thetotal2190.03, their sum, have to match. The rollup is read from the registry at export time and sums the statements without changing them: rollup.Read the index entry the export wrote for it:
shjq .rollup ~/tally-tutorial/2026-07-group/run.jsonjson{ "relation_type": "member_of", "documents": [ { "file": "rollup-meta%2Facme.json", "cloud": "meta", "project_id": "acme", "members": 3, "total": 2190.03, "currency": "EUR" } ] }members3 and the total have to match. The index names every rollup document the export wrote.
What you learned
- Adjustments live on relations, so every discount line traces back to one relation and its target: why adjustments live on relations.
- A meta-project is a project that owns nothing and exists to be related to: meta-projects and partners.
- A relation applies to a period its validity overlaps, which is why
valid_fromhad to be in July: temporal validity. - A run before finalization supersedes the run before it: runs before finalization.
- The adjustments array is specified in pricing adjustments and the statement members
base_cost,adjustments,net_costandtotalin statements.
Where to go next
Pay a reseller a kickback puts one tenant under a partner with a discount and a kickback.
It starts from the state this lesson leaves behind:
- everything Watch the month in Grafana left;
- the registry holding the three tenants, the meta-project
acmeand threemember_ofrelations; RUN_IDon the discounted run, which superseded the list-price run;- the export under
~/tally-tutorial/2026-07-groupbeside the one under~/tally-tutorial/2026-07; ACME_ID,ACME_1_ID,ACME_2_IDandACME_3_IDin the shell.