Finalize the month and export it
In this lesson you close July 2026 on the run Attribute a tenant to its Gardener project made, see the engine refuse to meter the month again and refuse a second close, and export the finalized run twice as JSON and once as CSV.
At the end the engine database holds 2026-07 finalized on RUN_ID, three new export directories stand under ~/tally-tutorial/ (2026-07-final, 2026-07-final-again and 2026-07-final-csv), and the simulator still holds its 84 notifications for the lesson after this one.
This lesson takes about 5 minutes.
Before you start
The state Attribute a tenant to its Gardener project leaves:
- everything Pay a reseller a kickback left;
- the registry complete, with eight projects, the meta-project
acme, the partnercloudhouseand six relations; RUN_IDon the attributed run;- the export under
~/tally-tutorial/2026-07-attributed.
jqon the path, which themake check-toolsof lesson 1 called.
If you closed that shell, restore it with this block:
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 RUN_ID="$(jq -r .run_id ~/tally-tutorial/2026-07-attributed/run.json)"This lesson meters nothing, so it needs no token, no counter sources and no port-forward. finalize and periods list read the engine database, and export reads the reporting database as well, for the membership its rollup sums. RUN_ID is read back off the last export's run.json, which carries the id of the attributed run.
A connection error from any go run command means the cluster is not up, and kind get clusters then prints nothing. That is cured by Set up your local Tally.
Close the month
Close July 2026 on the attributed run:
shgo run ./cmd/tally-engine finalize --period 2026-07 --run "$RUN_ID"textrun 923dc7cb-09da-44bf-9203-18140e0ac759 finalized, period 2026-07 closedThe line has to match with your own run id in it, the id
RUN_IDcarries. The run and the period move in one transaction.Finalization is a person's step because the finalized numbers may reach an ERP, and the hourly tick never takes it on the dev stack: why finalization is a human gate.
An error opening on
the run is not completedand naming the run assupersededmeansRUN_IDnames a run a later run replaced. The last line of the restore block above sets it to the id the last export carries, which is the cure where only the shell lost it. Where that export is itself the stale one, because the month was metered again after it was written, meter it once more in a shell that carriesTALLY_ENGINE_COUNTER_SOURCES,TALLY_ENGINE_VM_URLand the VictoriaMetrics port-forward, which Attribute a tenant to its Gardener project restores:go run ./cmd/tally-engine run --period 2026-07prints a fresh completed run id on its first line. Its warnings line has to read0 counterbefore you close over that run, because a run made without the counter source or without the port-forward completes with theegress_gbquantity missing rather than with an error, and a finalized run is changed by nothing but a correction. Put the id of that first line intoRUN_IDwithexport RUN_ID=<that id>before you close over it, becauseRUN_IDstill names the run the fresh one superseded. Closing over the fresh run produces the same numbers, because nothing in the month or the registry changed. An error opening onthe billing period is finalizedmeans this step ran before.List the periods the engine holds:
shgo run ./cmd/tally-engine periods listtext2026-07 finalized finalized_run=923dc7cb-09da-44bf-9203-18140e0ac759 finalized_at=2026-10-05T08:22:09ZThe first line has to read
2026-07 finalized, and itsfinalized_runhas to be the id inRUN_ID.finalized_atis your own.The
2026-08line is the tick's: the cluster's hourlytally-enginescheduler opens the months that have ended and moves them into their grace window. Its status is your own, and the line is absent on a machine whose cluster has not passed an hour mark yet.
See that the month is closed
Meter the month again:
shgo run ./cmd/tally-engine run --period 2026-07textError: the billing period is finalized: 2026-07 was closed by run 923dc7cb-09da-44bf-9203-18140e0ac759, and a finalized period is changed with tally-engine correct --period 2026-07 exit status 1The error has to match with your own run id in it. The refusal comes before a run row exists, and it names the command that changes a finalized period,
tally-engine correct --period 2026-07, which the next lesson runs.Close the month a second time:
shgo run ./cmd/tally-engine finalize --period 2026-07 --run "$RUN_ID"textError: the run is not completed: run 923dc7cb-09da-44bf-9203-18140e0ac759 is finalized, and a period is closed over a completed run, which tally-engine run --period 2026-07 produces exit status 1The error has to match with your own run id in it. The run's own status,
finalized, is what refuses the second close, and it is checked before the period is.The records of a finalized run are held by database triggers rather than by the CLI alone, so a script against the engine database cannot rewrite them either: why a finalized run is immutable. The hourly tick leaves a finalized month alone.
Export the statements as JSON
Export the finalized run with a rollup over
member_of:shgo run ./cmd/tally-engine export --run "$RUN_ID" --format json --out ~/tally-tutorial/2026-07-final --rollup member_oftextrun 923dc7cb-09da-44bf-9203-18140e0ac759 exported for 2026-07 as json into /Users/berendt/tally-tutorial/2026-07-final wrote run.json and 6 statements wrote kickbacks.json with 1 kickbacks wrote 1 rollup documents over member_ofThe last three lines have to match. The run id and the home directory are your own.
Read the head of the index the export wrote:
shjq '{run_id, kind, status, pricing_version, corrects_run_id}' ~/tally-tutorial/2026-07-final/run.jsonjson{ "run_id": "923dc7cb-09da-44bf-9203-18140e0ac759", "kind": "regular", "status": "finalized", "pricing_version": "2026-03", "corrects_run_id": null }kindregular,statusfinalized,pricing_version2026-03andcorrects_run_idnull have to match.run_idis your own.statusis the one member that differs from the export Attribute a tenant to its Gardener project wrote of the same run.Export the same run a second time, into a directory of its own:
shgo run ./cmd/tally-engine export --run "$RUN_ID" --format json --out ~/tally-tutorial/2026-07-final-again --rollup member_oftextrun 923dc7cb-09da-44bf-9203-18140e0ac759 exported for 2026-07 as json into /Users/berendt/tally-tutorial/2026-07-final-again wrote run.json and 6 statements wrote kickbacks.json with 1 kickbacks wrote 1 rollup documents over member_ofThe same three lines have to match.
Compare the two directories:
shdiff -r ~/tally-tutorial/2026-07-final ~/tally-tutorial/2026-07-final-againThe command prints nothing: the two directories are byte-identical, the rollup included, because no relation changed between the two exports. The statements are a function of the run alone, but a rollup reads the registry when the export runs, so a relation created or closed retroactively between two exports of one finalized run changes the rollup document, and with it the rollup index
run.jsoncarries: rollup.An
Error: --out: ... is not empty, and an export does not remove what an earlier one left thererefusal instead of the four lines above means the directory was used before, and a fresh directory name is the cure.
Export the statements as CSV
Export the same run as tables:
shgo run ./cmd/tally-engine export --run "$RUN_ID" --format csv --out ~/tally-tutorial/2026-07-final-csvtextrun 923dc7cb-09da-44bf-9203-18140e0ac759 exported for 2026-07 as csv into /Users/berendt/tally-tutorial/2026-07-final-csv wrote rated.csv with 3081 rated records wrote kickbacks.csv with 1 kickbackswrote rated.csv with 3081 rated recordsandwrote kickbacks.csv with 1 kickbackshave to match. 3081 is the rated record count themeteredline of every run of this track printed.List what the export wrote:
shls ~/tally-tutorial/2026-07-final-csvtextkickbacks.csv rated.csvTwo files, and no index beside them: every row of a table carries the run and its period, so a table says which run it belongs to on its own.
Read the header and the first two rows of the rated table:
shhead -3 ~/tally-tutorial/2026-07-final-csv/rated.csvtextrun_id,kind,corrects_run_id,period_from,period_to,cloud,platform,resource_type,resource_id,project_id,state,from_ts,to_ts,dimension,quantity,amount,currency 923dc7cb-09da-44bf-9203-18140e0ac759,regular,,2026-07-01T00:00:00Z,2026-08-01T00:00:00Z,os-sim,openstack,floating_ip,11327c4f-5563-4b70-9769-7ec877ff59b8,018504a6cc10019a40e3f9eef4dae529,active,2026-07-01T02:31:36Z,2026-07-22T01:55:52Z,count,1.0000,2.52,EUR 923dc7cb-09da-44bf-9203-18140e0ac759,regular,,2026-07-01T00:00:00Z,2026-08-01T00:00:00Z,os-sim,openstack,floating_ip,28b8f170-6b0c-474a-8b0d-a65b479c8e98,d5a8024946ddf673277b9e2490643a2c,active,2026-07-01T03:07:27Z,2026-08-01T00:00:00Z,count,1.0000,3.70,EURThe header has to match, and it is the column order of the table. There is one row per rated record, a resource, a state span and a dimension each. The run id in every row is your own, and the rest of a row is the seed's.
Count the lines of the table:
shwc -l < ~/tally-tutorial/2026-07-final-csv/rated.csvtext30823082 has to match, the 3081 records and the header. The leading spaces are how the
wcof macOS pads its count.Count the rows of one tenant:
shawk -F, '$10 == "005be5adeef3d87e280d03d9d57c38b4"' ~/tally-tutorial/2026-07-final-csv/rated.csv | wc -ltext827827 has to match: the rows whose tenth column,
project_id, is the tenant ofalpha. The table carries the tenant that owned each resource, and the attribution toalphastands in the statements alone, which is why the JSON export has no statement for that tenant while the table has its rows.Read the kickback table:
shcat ~/tally-tutorial/2026-07-final-csv/kickbacks.csvtextrun_id,kind,corrects_run_id,period_from,period_to,beneficiary,cloud,project_id,relation_id,scope,rate,base,amount,currency 923dc7cb-09da-44bf-9203-18140e0ac759,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, and there is one row, the
cloudhousekickback of Pay a reseller a kickback, with the ids in it your own.
What you learned
- A period moves from open through grace to finalized, and the close is the one step a person takes: the lifecycle.
- A finalized run cannot be metered again or rewritten, because the database refuses it: why a finalized run is immutable.
- The statements an export writes are reproducible because they are a function of the run and nothing else, while a rollup reads the registry as it runs: exports.
- The files an export writes are in files, the columns of the tables in CSV tables, and the flags of
finalize,periods listandexportin tally-engine.
Where to go next
Book the late events as a credit note releases the 84 notifications the simulator holds and books them as a correction of the month you just closed.
It starts from the state this lesson leaves behind:
- everything Attribute a tenant to its Gardener project left;
2026-07finalized onRUN_IDin the engine database;- the three export directories
~/tally-tutorial/2026-07-final,2026-07-final-againand2026-07-final-csv; - the simulator still holding its 84 notifications.