Add the service usage page behind a feature flag (#193) #730

Open
tobru wants to merge 7 commits from feat/service-usage into main
Owner

What

A member-facing Usage page for the current calendar month: accrued money so far and the expected total at month end, per instance.

Closes #193.

Design: docs/plans/2026-10-19-service-usage-view-design.md
Plan: docs/plans/2026-10-19-service-usage-view-plan.md

How

  • Read: get_instance_usage(sale_order_id, start, end) in core/odoo.py reads the month's
    account.analytic.line rows where instance_event = true, plus their sale.order.line rows.
    Many2one fields are unwrapped there, so no caller ever sees Odoo's [id, name] pairs.
    Cache 300 s, failure cache 60 s, 15 s read budget. No Odoo write.
  • Compute: core/usage.py groups the lines per instance and computes
    accrued = quantity x price and expected = accrued + day_qty x price x billable days,
    where a day is 24 x instance_size / 730. Money is Decimal, rounded half up per line, and the
    totals are sums of the rounded values.
  • Render: one block per instance with a composition bar showing what makes it expensive, a
    coloured dot per item, and the accrued and expected money per row. CSS only, no chart library.
  • Gate: Feature.SERVICE_USAGE, default off, and the page is hidden for organizations on a
    shared sale order (one order carries several tenants).

Decisions worth reviewing

  • Shared sale order: no page at all. Reading that order by order_id would show one customer
    another's instances and money. The menu entry is hidden with the same gate.
  • Trial: the forecast counts only the days after the trial end, and the banner names the trial
    end and the first billed day. A trial that covers the rest of the month says so instead.
  • The month is a UTC calendar month, because Odoo cuts days at 00:00 UTC. The header clock is
    formatted in Europe/Zurich in the view, because the date filter would convert it back to UTC.
  • Every member sees the page, not only admins. It sits in the Organization submenu below
    Invoices, which now renders for members too.
  • Prices exclude VAT, like everywhere else in the portal.

Tests

just test -> 3159 passed, project coverage 94%.

New coverage: the flag gates, the Odoo read and its cache and budget, the money and trial maths,
the join rules, the composition shares, the Swiss money format, the states, and the accessible
labels. core/usage.py is at 100%.

Before switching the flag on

Task 0 of the plan has not run: there are no prod Odoo credentials here, and preprod has zero
usage analytic rows. Two assumptions need one read-only check on prod:

  1. A managed service line uses a UUID as instance_id. If that UUID is not a ServiceInstance.name,
    those rows render unlinked under the product name. That is acceptable, not a blocker.
  2. A real usage row holds the order line instance_id in name, and unit_amount sums to
    qty_invoiced + qty_to_invoice for the same window.

Migration

core/0072_featureflag_service_usage is an AlterField on FeatureFlag.name for the new choice.
The post_migrate handler creates the row.

## What A member-facing **Usage** page for the current calendar month: accrued money so far and the expected total at month end, per instance. Closes #193. Design: `docs/plans/2026-10-19-service-usage-view-design.md` Plan: `docs/plans/2026-10-19-service-usage-view-plan.md` ## How - **Read:** `get_instance_usage(sale_order_id, start, end)` in `core/odoo.py` reads the month's `account.analytic.line` rows where `instance_event = true`, plus their `sale.order.line` rows. Many2one fields are unwrapped there, so no caller ever sees Odoo's `[id, name]` pairs. Cache 300 s, failure cache 60 s, 15 s read budget. No Odoo write. - **Compute:** `core/usage.py` groups the lines per instance and computes `accrued = quantity x price` and `expected = accrued + day_qty x price x billable days`, where a day is `24 x instance_size / 730`. Money is `Decimal`, rounded half up per line, and the totals are sums of the rounded values. - **Render:** one block per instance with a composition bar showing what makes it expensive, a coloured dot per item, and the accrued and expected money per row. CSS only, no chart library. - **Gate:** `Feature.SERVICE_USAGE`, default off, and the page is hidden for organizations on a shared sale order (one order carries several tenants). ## Decisions worth reviewing - **Shared sale order: no page at all.** Reading that order by `order_id` would show one customer another's instances and money. The menu entry is hidden with the same gate. - **Trial: the forecast counts only the days after the trial end**, and the banner names the trial end and the first billed day. A trial that covers the rest of the month says so instead. - **The month is a UTC calendar month**, because Odoo cuts days at 00:00 UTC. The header clock is formatted in `Europe/Zurich` in the view, because the `date` filter would convert it back to UTC. - **Every member sees the page**, not only admins. It sits in the Organization submenu below Invoices, which now renders for members too. - Prices exclude VAT, like everywhere else in the portal. ## Tests `just test` -> **3159 passed**, project coverage 94%. New coverage: the flag gates, the Odoo read and its cache and budget, the money and trial maths, the join rules, the composition shares, the Swiss money format, the states, and the accessible labels. `core/usage.py` is at 100%. ## Before switching the flag on Task 0 of the plan has not run: there are no prod Odoo credentials here, and preprod has zero usage analytic rows. Two assumptions need one read-only check on prod: 1. A managed service line uses a UUID as `instance_id`. If that UUID is not a `ServiceInstance.name`, those rows render unlinked under the product name. That is acceptable, not a blocker. 2. A real usage row holds the order line `instance_id` in `name`, and `unit_amount` sums to `qty_invoiced + qty_to_invoice` for the same window. ## Migration `core/0072_featureflag_service_usage` is an `AlterField` on `FeatureFlag.name` for the new choice. The `post_migrate` handler creates the row.
tobru force-pushed feat/service-usage from ac6ed2ccc5
All checks were successful
Tests / test (push) Successful in 2m16s
to 35c9af6818
All checks were successful
Tests / test (push) Successful in 1m53s
2026-11-03 15:45:34 +00:00
Compare
tobru force-pushed feat/service-usage from 35c9af6818
All checks were successful
Tests / test (push) Successful in 1m53s
to a37daa1217
Some checks failed
Tests / test (push) Has been cancelled
2026-11-05 14:51:42 +00:00
Compare
tobru force-pushed feat/service-usage from a37daa1217
Some checks failed
Tests / test (push) Has been cancelled
to a1a2e388be
All checks were successful
Tests / test (push) Successful in 2m4s
2026-11-05 14:55:33 +00:00
Compare
All checks were successful
Tests / test (push) Successful in 2m4s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/service-usage:feat/service-usage
git switch feat/service-usage

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/service-usage
git switch feat/service-usage
git rebase main
git switch main
git merge --ff-only feat/service-usage
git switch feat/service-usage
git rebase main
git switch main
git merge --no-ff feat/service-usage
git switch main
git merge --squash feat/service-usage
git switch main
git merge --ff-only feat/service-usage
git switch main
git merge feat/service-usage
git push origin main
Sign in to join this conversation.
No description provided.