Pay a reseller a kickback
In this lesson you register the CI tenant of the simulated cloud, create a partner, put the tenant under the partner's management with a 15% discount and a 10% kickback on one relation, run July 2026 again, and read the tenant's statement and the kickback report.
At the end the registry holds the CI tenant and the partner cloudhouse beside the rows of Discount a customer group, RUN_ID names a new run of July 2026, the JSON export of that run is under ~/tally-tutorial/2026-07-reseller, and CI_ID and PARTNER_ID are in the shell.
This lesson takes about 5 minutes.
Before you start
The state Discount a customer group leaves:
- everything Watch the month in Grafana left;
- the registry holding the three tenants, the meta-project
acmeand threemember_ofrelations; RUN_IDon the discounted run;- the export under
~/tally-tutorial/2026-07-group; ACME_ID,ACME_1_ID,ACME_2_IDandACME_3_IDin the shell.
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, and this lesson reads none of the four ACME_ variables.
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 CI tenant
Register the tenant the partner manages:
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": "10e287d5788957a2a331cabd1b5dccdf", "name": "ci"}' \ https://api.tally.127-0-0-1.nip.io:8443/api/v1/projects | jq .json{ "cloud": "os-sim", "created_at": "2026-10-05T08:21:35.13925Z", "external_id": "10e287d5788957a2a331cabd1b5dccdf", "id": "2b3bcd7a-94c4-4f92-8414-c48baabab266", "metadata": {}, "name": "ci", "platform": "openstack" }This is the CI tenant, which boots and deletes runners on every working day of the month. It carries 447 resources, more than any other tenant of the month, and its total in the list-price run is 698.29.
cloudos-sim,external_id,nameci,platformopenstackandmetadata{}have to match.idandcreated_atare your own.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.Read the id back into the shell:
shCI_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=10e287d5788957a2a331cabd1b5dccdf' | jq -r '.items[0].id')" export CI_ID echo "$CI_ID"text2b3bcd7a-94c4-4f92-8414-c48baabab266The id is your own, and it is the id the registration 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. An id printing
nullmeans the registration did not happen, and the cure is the call above.
Create the partner
Register the partner that manages the tenant:
shPARTNER_ID="$(go run ./cmd/tally-reporting-admin create-partner --external-id cloudhouse --name 'Cloudhouse')" export PARTNER_ID echo "$PARTNER_ID"textregistered partner cloudhouse 1b262c17-ad69-4471-9ef3-77de15f507baregistered partner cloudhouseis the CLI's notice on stderr. The id went to stdout and into the variable, and it is your own.A partner is a
projectsrow with platform and cloud bothpartner. It owns no resource and is what amanaged_byrelation points at: meta-projects and partners.Check that the
echoprinted one uuid before you go on. The target of the relation in the next step cannot be corrected inside July once it is sent, for the reason the run step gives.Error: partner cloudhouse: already registeredwith exit status 1 means the row exists from an earlier run of this step, andGET /api/v1/projects?cloud=partner&external_id=cloudhousepiped intojq -r '.items[0].id'reads its id the way the read-back above reads the tenant's.
Put the discount and the kickback on the management
Relate the tenant to the partner:
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/$CI_ID/relations" <<EOF | jq . { "target_id": "$PARTNER_ID", "relation_type": "managed_by", "valid_from": "2026-07-01T00:00:00Z", "metadata": { "pricing_adjustments": [ {"type": "discount", "rate": "0.15", "scope": "all", "description": "Cloudhouse end-customer discount"}, {"type": "kickback", "rate": "0.10", "scope": "all", "description": "Cloudhouse commission"} ] } } EOFjson{ "created_at": "2026-10-05T08:21:35.316925Z", "id": "04489747-96ab-45c1-9654-1d02f8f3152f", "metadata": { "pricing_adjustments": [ { "description": "Cloudhouse end-customer discount", "rate": "0.15", "scope": "all", "type": "discount" }, { "description": "Cloudhouse commission", "rate": "0.10", "scope": "all", "type": "kickback" } ] }, "relation_type": "managed_by", "source_id": "2b3bcd7a-94c4-4f92-8414-c48baabab266", "target_id": "1b262c17-ad69-4471-9ef3-77de15f507ba", "valid_from": "2026-07-01T00:00:00Z", "valid_to": null }relation_typemanaged_by,valid_from2026-07-01T00:00:00Z,valid_tonull and the twopricing_adjustmentsentries, adiscountof rate0.15and akickbackof rate0.10, both on scopeall, have to match.id,source_id,target_idandcreated_atare your own.A
managed_byrelation places the tenant under the partner and attributes no cost. The rates are strings,"0.15"and"0.10", never numbers, andvalid_fromis the first instant of July for the reason Discount a customer group gives.The engine applies what it collects in the fixed order surcharge, discount, project discount, kickback, whatever order the array has. A kickback is what the partner is owed rather than what the customer pays.
A 409 with
typeurn:tally:error:conflictand the detaila relation of this type between these projects is already activemeans this step ran before. A 400 withtypeurn:tally:error:validationwhose error is located atbody.target_idmeansPARTNER_IDis empty in this shell. A 422 withtypeurn:tally:error:validationand an error atbody.metadata.pricing_adjustments.0.ratereadinggot number, want stringmeans a rate was sent as a number. Atarget_idthat is the meta-project's id,ACME_ID, is stored by the registry like any other, and the run then drops the kickback with oneadjustmentwarning, which the next step names.
Run the month
Meter and rate July 2026 once more:
shgo run ./cmd/tally-engine run --period 2026-07textrun d49e7469-4052-45e2-a44d-0936e29b0906 completed for 2026-07 with pricing model 2026-03 metered 867 candidates into 940 usage records, 3081 rated records and 6 project statements applied 5 pricing adjustments superseded run 3935cd7f-204e-411a-baee-c8fa395abdd9 warnings recorded in runs.stats: 38 metering, 0 counter, 0 attribution, 0 adjustment, 2 unpriced resource types, 0 unreadable fields, 2 unregistered projectsThe
meteredline,applied 5 pricing adjustmentsand the warnings line have to match. Both run ids are your own, and the superseded one is the run Discount a customer group made.applied 5counts the three membership lines plus the two lines of this relation, one per adjustment on the CI tenant's statement. The warnings line ends in0 adjustment, so no kickback was dropped, and in2 unregistered projects, the two Gardener tenants the next lesson registers.Put the id from the first line of your own output in place of this one:
shexport RUN_ID=d49e7469-4052-45e2-a44d-0936e29b0906Every command below reads
RUN_ID.Read what stands there instead if the run printed something else:
1 adjustmenton the warnings line together with awarning: adjustment_kickback_target_not_partnerline means the relation's target is not the partner row, so the kickback was dropped and the discount stayed. There is no cure inside July: a relation is closed rather than deleted,valid_tohas to be aftervalid_from, and a relation whose validity overlaps a period at any instant applies to it (temporal validity), so the run bills with that relation as it stands. A reader who wants the numbers of this page starts both tracks again from Tear down your local Tally. This is why the step before had you checkPARTNER_ID.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 the reseller's statement
Export the run:
shgo run ./cmd/tally-engine export --run "$RUN_ID" --format json --out ~/tally-tutorial/2026-07-resellertextrun d49e7469-4052-45e2-a44d-0936e29b0906 exported for 2026-07 as json into /Users/berendt/tally-tutorial/2026-07-reseller wrote run.json and 6 statements wrote kickbacks.json with 1 kickbacksThe second and the third line have to match, and
wrote kickbacks.json with 1 kickbacksis the settlement this run has that the run before had none of. The run id and the home directory are your own. A directory that already holds files is refused, so this export gets a directory of its own.Read what the CI tenant owes:
shjq '{base_cost, adjustments, net_cost, kickback_total, total}' ~/tally-tutorial/2026-07-reseller/statement-os-sim%2F10e287d5788957a2a331cabd1b5dccdf.jsonjson{ "base_cost": 698.29, "adjustments": [ { "type": "discount", "relation_type": "managed_by", "relation_target": "cloudhouse", "relation_id": "04489747-96ab-45c1-9654-1d02f8f3152f", "scope": "all", "description": "Cloudhouse end-customer discount", "rate": 0.150000, "base": 698.29, "amount": -104.74 }, { "type": "kickback", "relation_type": "managed_by", "relation_target": "cloudhouse", "relation_id": "04489747-96ab-45c1-9654-1d02f8f3152f", "scope": "all", "description": "Cloudhouse commission", "rate": 0.100000, "base": 593.55, "amount": 59.36 } ], "net_cost": 593.55, "kickback_total": 59.36, "total": 593.55 }base_cost698.29 is the tenant's total at list price. Thediscountline is computed on that base: 698.29 times 0.15, rounded once to two places, isamount-104.74.net_cost593.55 is the base plus that amount, andtotalis the net. Thekickbackline is computed on the running net:base593.55 times 0.10, rounded once, isamount59.36, and it leaves the net alone.kickback_total59.36 stands besidenet_cost, not inside it.All of these have to match. The two
relation_idvalues are your own, and both name the one relation. The two lines stand in the order the engine applied them.List what the export wrote:
shls ~/tally-tutorial/2026-07-resellertextkickbacks.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 eight names have to match: six statements beside
run.jsonandkickbacks.json. There is nostatement-partner%2Fcloudhouse.json, because a partner is billed nothing and what it is owed is in the settlement.
Read the kickback report
Report what the run owes its partners:
shgo run ./cmd/tally-engine kickbacks --period 2026-07json{ "run_id": "d49e7469-4052-45e2-a44d-0936e29b0906", "kind": "regular", "corrects_run_id": null, "period_from": "2026-07-01T00:00:00Z", "period_to": "2026-08-01T00:00:00Z", "beneficiaries": [ { "beneficiary": "cloudhouse", "currency": "EUR", "kickback_total": 59.36, "projects": 1, "breakdown": [ { "cloud": "os-sim", "project_id": "10e287d5788957a2a331cabd1b5dccdf", "relation_id": "04489747-96ab-45c1-9654-1d02f8f3152f", "scope": "all", "rate": 0.100000, "base": 593.55, "amount": 59.36 } ] } ] }kindregular, the one beneficiarycloudhouseinEUR, itskickback_total59.36 equal to the statement's,projects1 and the onebreakdownentry with its cloudos-sim, its project10e287d5788957a2a331cabd1b5dccdf, its relation id, its scopeall, its rate 0.100000, its base 593.55 and its amount 59.36 have to match.run_idandrelation_idare your own.A month named alone reports the regular run that bills it. The document alone reaches stdout, so it pipes into a file as it is.
Report the same settlement as CSV:
shgo run ./cmd/tally-engine kickbacks --period 2026-07 --format csvtextrun_id,kind,corrects_run_id,period_from,period_to,beneficiary,cloud,project_id,relation_id,scope,rate,base,amount,currency d49e7469-4052-45e2-a44d-0936e29b0906,regular,,2026-07-01T00:00:00Z,2026-08-01T00:00:00Z,cloudhouse,os-sim,10e287d5788957a2a331cabd1b5dccdf,04489747-96ab-45c1-9654-1d02f8f3152f,all,0.100000,593.55,59.36,EURThe header has to match. There is one row per kickback record, and the ids in the row are your own.
Read the settlement the export left beside the statements:
shjq .beneficiaries ~/tally-tutorial/2026-07-reseller/kickbacks.jsonjson[ { "beneficiary": "cloudhouse", "currency": "EUR", "kickback_total": 59.36, "projects": 1, "breakdown": [ { "cloud": "os-sim", "project_id": "10e287d5788957a2a331cabd1b5dccdf", "relation_id": "04489747-96ab-45c1-9654-1d02f8f3152f", "scope": "all", "rate": 0.100000, "base": 593.55, "amount": 59.36 } ] } ]The export wrote the same settlement beside the statements, so what you hand the partner is in the export directory too.
What you learned
- A partner is an ordinary registry row, and a kickback is a line of its own computed on the running net that leaves the customer's net alone: how adjustments apply.
- What a partner is owed is a sum across statements, which is why the settlement is a report of its own: kickbacks and the rollup.
- The rate is stored on the relation rather than derived from the period's volume, so a correction rates the same way: why volume tiers are not computed.
- The settlement's members are in kickback settlement and the flags of
kickbacksin tally-engine.
Where to go next
Attribute a tenant to its Gardener project registers the two Gardener projects and moves their tenants' costs onto them.
It starts from the state this lesson leaves behind:
- everything Discount a customer group left;
- the registry holding four tenants, the meta-project
acme, the partnercloudhouseand four relations; RUN_IDon this run, which superseded the discounted one;- the export under
~/tally-tutorial/2026-07-reseller; CI_IDandPARTNER_IDin the shell.