Add the admin endpoint for the billable usage export
What does this MR do and why?
Adds the admin endpoint that serves the air-gapped billable usage export as a JSON file download, behind the billable_usage_export flag:
Admin::Licenses::BillableUsageExportsControllerwith three actions:show(/admin/license/billable_usage_export) returns the export document plus the page-only values for the preview screen, with no side effects;download(/admin/license/billable_usage_export/download) serves the document as a file;terms(/admin/license/billable_usage_export/terms) lists the subscription terms that can be exported.showanddownloadtake an optionallicense_idto pick a term.Utilization::BillableUsage::Exports::Period.for_license- resolves the export window from a licence.Utilization::BillableUsage::Exports::Term- the exportable terms: the current one and the previous one.- A
billable_usage_export_logsrow for every file downloaded, from !256910 (merged). ee/app/assets/javascripts/lib/utils/path_helpers/instance_admin.jsandconfig/routing/gitlab_routes.json: regenerated for the three new routes (required by thejs-routesand cells routes checks).
Closes https://gitlab.com/gitlab-org/customers-gitlab-com/-/work_items/18552. Targets master. The payload it builds on, !257207 (merged), is merged.
Open before merge: a paired route sync in gitlab-org/cells/http-router for the three new admin routes, so cells-routes:router-in-sync passes. All three match the router's existing /admin/* rule.
The billable_usage_export flag is a wip flag while the export UI is built; it moves to beta, with a rollout issue, when it is ready to roll out.
The period
Period.for_license(license) runs from the licence start to the earlier of two days: the last completed UTC day, and the day before the licence expires (expires_at - 1, since Gitlab::License#expired? is Date.today >= expires_at). A running term still ends yesterday. A past term runs to its last day. A licence with no expiry ends yesterday.
The "last completed day" uses Time.current.utc.to_date, not Date.current: usage_date is written as a UTC date, so an instance east of UTC would otherwise export a day that is still changing.
The late-arrival allowance (LATE_ARRIVAL_GRACE_DAYS, currently 0) is still open with product. The renewal question is settled by the subscription terms below.
Subscription terms
After a renewal, License.current moves to the new licence. A report that always started at the current licence start would strand the last month of the old term if it was never uploaded. So the export page has a "Subscription term" dropdown, as settled in the design and the thread on #18552. The default is the current term, and the previous term is the other choice. Each term is its own file, so each file carries one licence key.
terms returns the exportable terms, newest first:
[{ "license_id": 12, "start_date": "2026-02-01", "end_date": "2027-01-31", "current": true }]end_date is the term end (the day before the licence expires), used for the dropdown label. Term.available takes License.history and:
- keeps licences that have started
- drops trial licences
- keeps one licence per start date, the newest, because a reissued licence keeps the same start
- keeps the first two (
LIMIT = 2: current and previous)
A trial licence is not an exportable term, so on a trial instance show and download return 404. terms is not subject to the 422 check below, so on the day a licence starts the current term is still listed.
show and download return 404 for a license_id that is not in the listed terms. With no license_id they use the current term.
Why the endpoint is a separate controller
Admin::Licenses::BillableUsageExportsController is a sibling of Admin::Licenses::UsageExportsController rather than a new format.json on it, because feature_category is set per class and this export belongs to consumables_cost_management, not seat_cost_management.
It does set the same license_usage_data_exported flag the CSV export sets. That flag means "you have submitted your seat usage" and drives a monthly banner; since this file always carries the seat link series, downloading it meets that obligation. It is set in the success path rather than in an after_action, so a build that errors cannot claim an export that never left. See the thread on #18555; the banner's copy, which still names the CSV, needs a separate product decision.
All three actions return 404 unless Feature.enabled?(:billable_usage_export, :instance) and Gitlab::BillingEvents::Client.new.local_persistence_enabled?. The latter already encodes the local_billing_persistence flag, the SaaS exclusion and the offline licence check, so the endpoint cannot appear anywhere the aggregate table cannot be written. The only request parameter is the optional license_id on show and download.
Show and download
The preview needs the document without recording an export, so the two are separate actions sharing one build method. Only download writes the log row and sets license_usage_data_exported. A single action whose side effects depend on a request parameter would be a GET that records an export when prefetched or retried, and the licence resource already has the same shape with its own download. The preview and the download are built separately, so their generated_at and checksum differ, while the records are the same.
download serves only the file. show returns:
{ "document": { "...the export file..." }, "licensed_seats": 450, "last_exported_at": "2026-08-01T09:12:00Z" }documentis the same documentdownloadserves. A spec checks they are equal.licensed_seatsis the selected term's licence seat count. The preview needs it for the Licensed seats column, and the file does not carry it.last_exported_atis the latestgenerated_atinbillable_usage_export_logsfor that term'speriod_start, or null. It drives "You last exported on ...".
These two values are only for the page, so they sit beside the document rather than inside it. That keeps the file format CustomersDot parses unchanged.
On the day a licence starts no day has completed yet, so show and download return 422 and record nothing.
Recording the export
Each file served is recorded in billable_usage_export_logs, the audit table from !256910 (merged), before send_data. The row takes its period, generated_at, record_count and checksum from the document itself, so it matches the file exactly. It uses create!: if the row cannot be written, the request fails and no file leaves, since an export with no record would leave nothing to reconcile an upload against. A build that errors writes no row. The request spec covers all three.
The export page now reads the log back through ExportLog.last_generated_at_for(period_start:), for last_exported_at. The model comment that said nothing reads the table back is updated.
Database
No migration of its own. One INSERT into billable_usage_export_logs per file served, a table from !256910 (merged) that holds a handful of rows per subscription per year. show adds one read of the same table.
The insert download makes through ExportLog.create!, captured from the test database (this capture had no signed-in user, so generated_by_id is omitted; the action sets it to the admin):
INSERT INTO "billable_usage_export_logs" ("created_at", "updated_at", "generated_at", "period_start", "period_end", "record_count", "payload_checksum")
VALUES ('2026-09-30 06:21:05.749238', '2026-09-30 06:21:05.749238', '2026-09-01 09:00:00', '2026-08-01', '2026-08-31', 62, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa') RETURNING "id"Insert on billable_usage_export_logs (actual time=0.042..0.042 rows=1 loops=1)
Buffers: shared hit=8
-> Result (actual time=0.004..0.004 rows=1 loops=1)
Buffers: shared hit=1The table is new and cell-local, so Database Lab has no data for it. The plan below is from a local GDK database seeded with 100,000 rows (33 MB with indexes) across 100 yearly terms and 50 users, far beyond what an instance writes:
INSERT INTO billable_usage_export_logs (created_at, updated_at, generated_at, generated_by_id, period_start, period_end, record_count, payload_checksum)
SELECT now(), now(), now() - (i || ' minutes')::interval, (i % 50) + 1,
make_date(1950 + i % 100, 1, 1), make_date(1950 + i % 100, 12, 31),
i % 10000, encode(sha256(i::text::bytea), 'hex')
FROM generate_series(1, 100000) i;ANALYZE billable_usage_export_logs;
-- Insert on billable_usage_export_logs (actual time=0.077..0.077 rows=1 loops=1)
-- Buffers: shared hit=13
-- -> Result (actual time=0.003..0.003 rows=1 loops=1)
-- Buffers: shared hit=1
-- Planning Time: 0.013 ms
-- Execution Time: 0.087 msThe read show makes, the query ActiveRecord generates for maximum(:generated_at):
SELECT MAX("billable_usage_export_logs"."generated_at") FROM "billable_usage_export_logs" WHERE "billable_usage_export_logs"."period_start" = '2026-02-01'There is no index on period_start. The table gets one row per admin download, a handful per subscription per year, so a scan is trivial.
References
- Issue: https://gitlab.com/gitlab-org/customers-gitlab-com/-/work_items/18552
- Epic: https://gitlab.com/groups/gitlab-org/-/epics/23205
- Export stack, in merge order:
- !257174 (merged) - seat link daily series and the coverage block (merged)
- !258349 (merged) - partition and rename the per-namespace usage table (merged)
- !258358 (merged) - instance-wide daily totals and their worker (merged)
- !256937 (merged) - export records: scopes, record id, wire mapping (merged)
- !256910 (merged) - export history table (merged)
- !257207 (merged) - period, checksum, payload envelope (merged)
- !257491 (merged) (this MR) - admin endpoint,
billable_usage_exportflag, export history write
How to set up and validate locally
Needs the flags on and an offline licence, set up as in !249324 (merged):
Feature.enable(:billable_usage_export)
Feature.enable(:local_billing_persistence)
Gitlab::BillingEvents::Client.new.local_persistence_enabled? # => true, or the path 404sSigned in as an admin with admin mode enabled, open /admin/license/billable_usage_export to see the JSON with document, licensed_seats and last_exported_at; no export is recorded. Then open /admin/license/billable_usage_export/download: the browser downloads billable_usage_export_<start>_<end>.json, a billable_usage_export_logs row is written, and the file's contents can be checked as in !257207 (merged)'s validation steps after doc = Gitlab::Json.parse(File.read('export.json')). Reload show and last_exported_at now shows that download.
Then the terms:
- Open
/admin/license/billable_usage_export/terms. It lists the current term first, with"current": true, and the previous term second if there is one. - Open
showanddownloadwith?license_id=<previous license_id>. The period starts at the previous licence's start and ends the day before it expired. The file'slicense_keyis the previous licence's key, not the current one. - A
license_idthat is not in thetermslist returns 404.
Setting up a previous term needs a second, expired offline licence in License.history.
Then the gating, which is most of the risk in this MR:
Feature.disable(:billable_usage_export)and the path returns 404. Same for a non-offline licence, and for a non-admin.- The period in the filename and in
period_start/period_endruns from the licence start to yesterday, in UTC, for the current term. SettingTime.zone = 'Asia/Tokyo'must not move it. /admin/license/usage_export.csvstill works and still setslicense_usage_data_exported. The new endpoint sets it too, since the file carries the seat link series, but a 422 from it leaves the flag untouched.
Specs
bundle exec rspec ee/spec/models/utilization/billable_usage/exports/period_spec.rb
bundle exec rspec ee/spec/models/utilization/billable_usage/exports/term_spec.rb
CI=true bundle exec rspec ee/spec/requests/admin/licenses/billable_usage_exports_controller_spec.rbThe request spec needs CI=true in a GDK without the sass gem; the model specs must run without it, or the test environment will not start Gitaly.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.