Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/content/docs/changelog/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ The first stable **10.0.0** release. fullstackhero is now a complete **.NET 10 m

See the dated entries below for the complete list of changes that shipped into 10.0.0.

## 2026-05-30

- **Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes).** A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller's tenant. Because `BillingDbContext` is intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly — and several didn't. A tenant admin (who holds the basic `Billing.View`/`Billing.Manage` permissions) could read, **issue, pay, or void another tenant's invoices** by id, **reassign or cancel another tenant's subscription** via a body `tenantId`, **read or fabricate another tenant's usage**, or trigger **platform-wide invoice generation**. The by-id read/PDF paths and every mutation path now gate on the root operator — the operator acts cross-tenant, every other tenant is pinned to its own — and `POST /api/v1/billing/invoices/generate` is now operator-only. Separately, a role-permission filter only stripped a `Permissions.Root.` name prefix that matches **no** real operator permission, so a non-root tenant admin with `Roles.Update` could grant their own role the operator-only `Tenants.*` / `Platform.*` permissions and escalate to managing every tenant; the filter now keys off the registered `IsRoot` flag. Existing isolation tests missed all of this because they always authenticate with a *matching* tenant header — they never exercised one tenant's token acting on another's data. New integration tests cover each scenario. (The `tenant`-header-vs-JWT-claim path was investigated and is **not** affected — Finbuckle's claim strategy binds the resolved tenant to the JWT claim for non-root callers.)
- **The API now serializes enums as their string names (contract change).** Every enum in an API response is emitted as its name (`"Active"`, `"Paid"`, `"Security"`) instead of a numeric value, via a global `JsonStringEnumConverter`; reading still accepts either form, so request bodies are unaffected. `[Flags]` enums (`AuditTag`, `BodyCapture`) stay numeric. Both bundled React apps already mirror this as string-union types — but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription's `status` serialized as `0` and surfaced as a stray "0" in the dashboard.
- **Billing correctness.** The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice *purpose*), so overage went unbilled — it's now scoped to the usage invoice. A same-plan renewal advanced the tenant's validity but left the subscription's end date unchanged, so the dashboard's subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant "provisioned" with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant's validity can no longer be adjusted.
- **Front-end polish.** The **admin** console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with `403` on submit) and shows a real error state on the invoice page instead of a stuck "Loading…". The **dashboard** landing page's validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list.

## 2026-05-28

- **Tenant billing is now complete end-to-end — expiry/renewal emails, PDF invoices, and a tenant-facing billing view.** Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (`tenant-expiry-scan`, 02:00 UTC) classifies every active tenant as *nearing expiry*, *in grace*, or *expired* and emails the tenant admin — deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as **PDF** (`GET /api/v1/billing/invoices/{id}/pdf`, QuestPDF behind a swappable `IInvoicePdfRenderer`); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The **dashboard** gains a `/subscription` page (plan, validity, usage, recent invoices), a global expiry/grace **warning banner**, and invoice detail with PDF download; the **admin** console gets a PDF button, client-side plan-form validation, and an **Adjust validity** operator override (`POST /tenants/{id}/adjust-validity`) that sets a tenant's expiry directly with no invoice — for comps and corrections. New config key `Billing:ExpiryNotificationLeadDays` (default 7). *Note: QuestPDF's Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license — the dependency is isolated behind `IInvoicePdfRenderer` if you prefer to swap it.*
Expand Down