Manual QA Plan — Reports earner & stakeholder screens
Covers the self-scoped surfaces of the Reports MFE port (#2102) — the first screens in that app that are not administrator surfaces.
| Shipped in | Commit on docs/new-schema-design | What |
|---|---|---|
| #2279 | 118d88aac | My Earnings + the self-scoped read path (#2272) |
| #2286 | cdbfad219 | My Statement, My Commission Rules, the rule-behind-a-row expansion |
| #2328 | dbc204259 | Revocation closes rows as well as lists (#2287) |
What this plan does not cover. Earlier tiers of #2102 have their own QA comments on that issue (Tier 0 on 2026-08-17, Tier 8 + facility cost entry on 2026-09-01, the commissions/revenue-sharing brief on 2026-09-04). This plan does not repeat them. It covers the three earner-facing screens, the admin surfaces this work touched, and the security properties review found — nothing else.
Why this plan leans on curl
Most of it is clickable. Three of the highest-value checks are not, because the defects review found were reached by removing something a browser always sends, or by asking for a URL the UI never builds:
- omitting
?mine=truereturned every rule in the tenant, through the administrator serializer, to any practitioner - fetching a colleague's row by id succeeded even though the list correctly hid it
- revoking a permission emptied the list and left the rows readable
A tester clicking through the app cannot produce any of those requests. The §4 checks exist for exactly that reason and are the ones to run first if time is short.
1. Pre-flight
- Base branch deployed to staging at
dbc204259or later (git log --oneline -1 origin/docs/new-schema-design). - Demo data seeded for the commission tenant:
cd backend && bundle exec rake dev_seed:commission_earningsExpectcommission calculations: 18,revenue share distributions: 6,done. Re-run it once more — the counts must be identical. Rising counts is a bug (it was one: the task created a duplicate rule per run). - Staff records seeded, or every staff login lands on an empty profile picker:
bundle exec rails runner 'load Rails.root.join("db/seeds/identity/06b_staff.rb").to_s; load Rails.root.join("db/seeds/identity/07b_staff_roles.rb").to_s'(Included in a fullrake db:seed.) -
SHOW_REPORTS_APPenabled for the test tenant.
Accounts
All country code +91. Full list and caveats in backend/db/seeds/identity/TEST_ACCOUNTS.md.
| Role | Phone | Password | 2FA | Tenant |
|---|---|---|---|---|
| practitioner (earner, has data) | 9876543210 | Doctor123! | SMS | City Hospital Group |
| staff (nurse) | 9876543212 | Nurse123! | none | City Hospital Group |
| admin (tenant-wide billing) | 5555555558 | FacilityAdmin123! | none | City Hospital Group |
| admin (second) | 5555555559 | Hospital123! | none | City Hospital Group |
Three traps, all previously mistaken for bugs — do not file these:
- Sarah Johnson (
5551234567) is TOTP. A TOTP code is generated by an authenticator app and is never transmitted, so none arrives in the dev Slack sink. Use Lisa Anderson (5555555558) instead for tenant-wide billing. - The owner account (
5555555557) is in a different tenant — Dr. Smith's Practice, which has no billing data. Its screens are empty because they are empty. - All seeded demo data lives in City Hospital Group. An account in any other tenant shows empty states correctly.
2. My Earnings — /reports/my-earnings
Sign in as the practitioner (9876543210).
- Sidebar shows My Earnings, My Statement, My Commission Rules — and not Overview, Commission Management, or Export.
- Landing on
/reportsredirects to My Earnings (not Overview, which 403s for this role). - Rows appear, each one this practitioner's own commission. Every row's earner is them.
- Summary cards: total, and a paid figure that is not
0.00when paid rows exist. (A0.00paid beside a real total was a bug — uppercase/lowercase enum keys.) - Period filter: change the month → rows and summary both change consistently.
- Filter state survives a tenant/profile switch — switch profile and back; filters must reset rather than leak across tenants.
- Pagination: page size comes from the shared scale; page 2 loads and the summary still reflects the filtered set, not just the page.
- Status labels render as words, not raw enum values.
The rule behind a row
- A row whose commission has a rule shows the rule name as a clickable control, not plain text.
- Clicking expands the row and shows: your rate, method, facility, effective dates, currently-in-effect.
- The rate shown is yours. Where an assignment carries an override, the expansion shows the override and labels it
agreed for you— not the rule's headline rate. - The expansion never shows another person's name, a count of other people, or another person's rate.
- Collapse and re-expand → no duplicate fetch storm, no stale content from a different row.
- A row with no rule does not offer the control.
3. My Statement — /reports/my-statement
Sign in as the practitioner (seeded as a revenue-share stakeholder in two facilities).
- Rows appear, one per facility/period the person holds a share in.
- Amounts render;
—rather than a blank orNaNwhere a figure is absent. - Page total is a real number, never
NaN. - Status per row (paid / approved / pending) renders as words.
- No share-of-pool figure anywhere. No "6.25% share", no facility distributable revenue, no total revenue. (This inverts against the payout amount to reveal the whole facility's monthly pool — it was being rendered.)
- Sign in as staff (
9876543212), who holds no stakeholder row: the screen shows an empty state that distinguishes "you hold no share here" from "your share paid nothing this period".
4. Security checks — run these first
These need a bearer token. Get one from browser devtools (Application → Local Storage → the auth entry) after signing in as the practitioner, and export it:
TOKEN='<paste>'
API=https://<staging-host>4a. Omitting mine must not widen the caller
-
curl -s -H "Authorization: Bearer $TOKEN" "$API/billing/commission_rules" | jq '.data | length, [.[].name]'Must return **only rules that reach this practitioner** — the same set the My Commission Rules screen shows. Before the fix this returned every rule in the tenant. -
curl -s -H "Authorization: Bearer $TOKEN" "$API/billing/commission_rules" | jq '[.data[] | keys] | flatten | unique'Must **not** contain `assignments`, `assignees_count`, `practitioners_count`, or `staff_count`. Those carry colleagues' identities and override rates. -
Repeat both with
?mine=0,?mine=false,?mine=off, and?mine[]=x. Every one must stay narrowed. (The narrowing must come from the caller's permission, never from the parameter.)
4b. A colleague's row by id
Get a colleague's calculation id as the admin, then request it as the practitioner:
-
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" "$API/billing/commission_calculations/<colleague-id>"→ 404. - Same for
/billing/commission_settlements/<colleague-id>→ 403 or 404. - Same for
/billing/revenue_share_distributions/<colleague-id>→ 403 or 404. - And the positive control — the practitioner's own calculation id → 200, returning that row.
4c. Revocation closes rows, not just lists
Needs an admin to toggle the permission on the practitioner's membership (Account → Users → the practitioner → permissions), or a console.
- With
view_own_commissionsrevoked: My Earnings and My Commission Rules disappear from the sidebar, andGET /billing/commission_calculations→ 403. - Still revoked, request the practitioner's own calculation by id → 404. Owning the row must not be enough. (This was the #2287 bug: the list went away, the rows stayed readable.)
- Re-grant → both the list and the row come back.
- Same pair for
view_own_statementagainst/billing/revenue_share_distributions.
4d. A billing administrator is not caught by the tightening
- As the admin (
5555555558), the tenant-wide surfaces still work: Overview, Commission Management, every calculation and settlement including other people's. - The admin can still PATCH a calculation they do not personally earn.
5. My Commission Rules — /reports/my-commission-rules
- Sidebar entry appears for practitioner and staff; the page lists the rules that reach the signed-in person.
- Each row is labelled how it reaches them — Agreed with you (named on it) / Applies to all work at this facility / Paid you previously (an arrangement since withdrawn).
- A withdrawn personal arrangement reads Paid you previously, never "All work at this facility". Those mean opposite things.
- The same labels appear on the row expansion in My Earnings. Open both and compare — a rule must read identically in both places. (They disagreed twice.)
- Your rate per row, with
agreed for youwhere it comes from your own override. - A rule with no personal rate and no fixed amount reads Tiered, not
—. (—reads as "you are paid nothing".) - No colleague, no colleague count, no colleague rate anywhere on the page.
- Staff login: same screen, same rules — a staff member can be an earner.
6. Regression pass — administrator surfaces this work touched
Sign in as the admin. Nothing below should have changed; any flip is a regression.
- Overview / Dashboard loads. It previously called a legacy route and logged users out — no logout, no 401 loop.
- Leave the Dashboard open on a 403 (e.g. as a role without billing) → it must stop polling, not re-request every 30s forever.
- Commission Management → Calculations: list, filter, bulk update, export all work.
- Open a calculation's details modal. If the fetch fails, an error toast appears — the modal must not open silently empty. (The 403→404 change silenced the only signal.)
- Commission Management → Rules: the administrator rule view still shows assignments and assignee counts. That view is unchanged and should show them.
- Revenue Sharing → Payouts: the
% sharecolumn still renders for administrators. - A practitioner sees no sidebar entry that 403s on click. Click every entry offered.
7. Known gaps — do not file these
| Behaviour | Tracked |
|---|---|
A practitioner earning under a tiered rule sees nothing in My Earnings. tiered_commission_calculations has no column naming a person, so tiered earnings are unattributable per earner in the schema. | #2289 |
A settlement fetched with include_calculations=true renders its nested calculations without the calculation policy. Members are same-earner by construction today, but nothing enforces it. | filed in #2328 review |
Rows with a NULL earner_type match neither the read predicate nor the scope, so an earner cannot see pre-migration settlements the payout path would still pay. Fails closed — an access gap, not a leak. | filed in #2328 review |
| My Commission Rules has no pagination control; it requests 100 and would silently truncate beyond that. | acceptable at current scale |
8. What a machine cannot settle — needs a person
The automated suite proves shape and permission. It cannot judge these, and they are the reason this plan exists:
- Are the figures right? Pick one row in My Earnings, open its rule, and recompute by hand: gross × rate (or the override) = the commission shown. The tests assert a number is rendered, never that it is the correct number.
- Would an earner understand this screen without help? Specifically whether
Paid you previouslyandagreed for youread as intended to somebody who did not build them. - Is anything on these three screens something a practitioner should not see about a colleague? Read every field with that question in mind. Two separate leaks of exactly this kind reached review, both through a serializer that was correct for administrators and wrong for earners.
- Does the empty state tell the truth? "You hold no share here" and "your share paid nothing this period" are different facts; confirm the screen distinguishes them rather than showing one message for both.
Rollback
All three PRs are read-path changes plus one CONCURRENTLY-created index (idx_billing_invoices_tenant_issue_date). No data migration, no destructive DDL. Revert = revert the squash commits in reverse order (dbc204259, cdbfad219, 118d88aac); the index is safe to leave or drop with DROP INDEX CONCURRENTLY.