Skip to content

Tenant billing completion + activation/impersonation fixes#1270

Merged
iammukeshm merged 13 commits into
mainfrom
feat/tenant-activation-confirm
May 28, 2026
Merged

Tenant billing completion + activation/impersonation fixes#1270
iammukeshm merged 13 commits into
mainfrom
feat/tenant-activation-confirm

Conversation

@iammukeshm
Copy link
Copy Markdown
Member

Summary

Completes the SaaS tenant billing feature end-to-end and bundles a few pre-existing tenant activation / impersonation fixes that were already on this branch.

Billing completion (the main feature):

  • HardeningTenantService.RenewAsync now uses the injected TimeProvider (was DateTime.UtcNow); X-Subscription-Grace: <daysLeft> response header during the grace window (set via Response.OnStarting so it survives error responses); new operator-only AdjustTenantValidityCommand (POST /tenants/{id}/adjust-validity) to set a tenant's expiry directly with no invoice (comps/corrections, backdating allowed).
  • Notifications — daily tenant-expiry-scan Hangfire job (02:00 UTC) classifies each active tenant as nearing expiry / in grace / expired and emails the tenant admin, deduped once per state per validity window (re-arms on renewal) via a TenantExpiryNotice ledger. Issuing an invoice also emails the tenant. New config key Billing:ExpiryNotificationLeadDays (default 7).
  • PDF invoicesIInvoicePdfRenderer (QuestPDF, Community license set, swappable behind the interface) + tenant-scoped GET /api/v1/billing/invoices/{id}/pdf (404 cross-tenant; one endpoint serves both operator console and tenant self-service).
  • FrontendGET /api/v1/tenants/me/status; dashboard /subscription page (plan, validity, usage, recent invoices), global expiry/grace warning banner, invoice detail + PDF download; admin invoice-PDF button, client-side plan-form validation, and an Adjust-validity operator dialog.

Production bug fixed along the way: background-published integration events crashed the generic webhook fan-out (it captures the ambient tenant at DbContext construction, which is null in a no-HTTP scope). Background publishers now install the Finbuckle tenant context before publishing.

Also on this branch (pre-existing, unrelated): admin tenant activate/deactivate confirmation + redundant tenant-chip removal, dashboard permission-gating of admin-only nav, and end-impersonation-on-cross-app-handoff fixes.

Test plan

  • Backend build clean with TreatWarningsAsErrors
  • Integration.Tests green (expiry/grace boundaries, double-renew, renew-while-lapsed, scan dedup, event idempotency, cross-tenant PDF/invoice 404, backdating)
  • Billing.Tests + Multitenancy.Tests + Architecture.Tests green
  • Both React apps: build + lint + Playwright (dashboard subscription/banner/invoice specs, admin billing/tenant specs)
  • Docs updated in the separate docs repo (billing module page + changelog) — golden rule Localization with JSON files #10

🤖 Generated with Claude Code

iammukeshm and others added 13 commits May 28, 2026 14:55
…nt chip

Activate/deactivate now goes through a styled ConfirmDialog (important, blast-
radius-y operation) instead of firing immediately. Removes the topbar TENANT
pill since the user dropdown already shows the active tenant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an operator started impersonation from another app (no dashboard session
stashed), ending it minted + installed the operator's account here — dropping a
root-tenant SuperAdmin into the tenant dashboard, which login otherwise forbids.
Now, with no stash, end-impersonation logs out cleanly instead of restoring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sessions + Audit trail require elevated permissions (Sessions.ViewAll /
AuditTrails.View), so impersonated or limited users landed on a 403 page. The
shared SidebarNavBody now hides nav items whose permission the user lacks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The server end call has a 30s timeout, so an intermittently-slow response left
the banner stuck on "Ending…". For a cross-app handoff the local impersonation
token is disposable, so log out immediately and fire the server end best-effort
in the background (grant revocation + audit) instead of blocking the UI on it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design spec for completing the tenant billing feature to production grade:
backend hardening (clock fix, grace header, indexes, AdjustTenantValidity),
Phase 3 notifications (scan job + expiry/invoice events + email handlers),
Phase 4 PDF invoices (QuestPDF behind IInvoicePdfRenderer), dashboard
self-serve view + expiry banners, and the full regression/integration test plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… exist)

Add the task-decomposed implementation plan for the production-hardening pass
and correct the spec: the UsageSnapshot unique index and Subscription
partial-unique active index already exist (audit was wrong), so no index
migration is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RenewAsync derived "now" from DateTime.UtcNow instead of the injected
TimeProvider the rest of the service uses, making the renewal stacking math
uncontrollable in tests. Add a unit test that pins the clock and asserts the
period starts from the injected provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Emit X-Subscription-Grace (days left) during the grace window via
  Response.OnStarting so the signal survives error responses too.
- Add AdjustTenantValidityCommand: a root-only operator override that sets a
  tenant's ValidUpto to an explicit date (backdating allowed) with no invoice,
  subscription, or renewal event — for comps, support extensions, or immediate
  expiry. Logs the change for audit.
- Tests: grace-header present in-grace / absent when active; adjust-validity
  future + backdate-to-expire, no-billing-side-effect, 400/401/403 authz;
  GetStatusAsync expiry-state boundary unit tests (at ValidUpto / grace end).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…art)

Cover renewing twice stacks two terms, and renewing a tenant lapsed 30 days ago
restarts the term from now rather than the past validity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New integration events: TenantNearingExpiry / TenantEnteredGrace / TenantExpired
  (Multitenancy.Contracts) and InvoiceIssued (Billing.Contracts).
- BillingService publishes InvoiceIssued when a subscription invoice is issued.
- TenantExpiryScanJob: daily Hangfire scan classifies each active tenant's state,
  dedups via a new TenantExpiryNotice ledger in TenantDbContext (one notice per
  tenant/state/validity period, re-arms on renewal), and publishes the matching event.
- Notifications module gains email handlers (IMailService) for the 4 events.
- Config: Billing:ExpiryNotificationLeadDays (default 7).

Critical fix: background-published lifecycle events must set the Finbuckle tenant
context before publishing — BaseDbContext (e.g. WebhookDbContext via the webhook
fan-out handler) captures the ambient tenant at construction, so a no-context
background scope made its query filter NRE. The scan job now installs the tenant
context before publish; the webhook fan-out + email handlers then run correctly.

Tests: scan-job records the right notice + dedups on re-run + emails the admin;
issuing a subscription invoice emails the admin. NoOpMailService now captures
sent mail so email behavior is assertable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- IInvoicePdfRenderer + QuestPDF implementation (A4 invoice: header, bill-to,
  period, line-items table, subtotal, notes). License set to Community; the
  dependency is isolated behind the interface so it's swappable.
- GET /api/v1/billing/invoices/{id}/pdf streams application/pdf. Reuses the
  caller-tenant scoping of GetInvoiceById (View is basic), so one endpoint serves
  both operators and tenant self-service; cross-tenant ids return 404.
- Tests: renderer produces a valid %PDF for full + empty invoices; endpoint
  returns the tenant's own invoice as PDF and 404 for another tenant's.

Note: QuestPDF Community license is free under $1M USD/yr revenue; documented for
downstream commercial users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET /api/v1/tenants/me/status resolves the calling tenant from context and
returns its plan, validity, and expiry/grace state — powering the dashboard's
plan view and expiry warning banner. Tests cover own-status resolution and the
unauthenticated 401.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etion)

Dashboard (tenant-facing):
- /subscription page: current plan, validity, expiry badge, usage with limits/
  overage, recent invoices.
- Global expiry/grace banner in the AppShell (InGrace warning + nearing-expiry
  info), driven by GET /tenants/me/status; dismissible per session.
- Invoice detail page with line items + Download PDF.
- Fixed SubscriptionStatus enum to match backend; typed invoice line items;
  getMyInvoices now consumes the paged envelope.

Admin (operator):
- Download PDF button on invoice detail.
- Adjust-validity dialog on tenant detail (operator override, no invoice),
  gated by the same permission as Renew.
- Plan-form client-side validation: non-negative prices + overage rates.

Tests: route-mocked Playwright for the dashboard subscription page, expiry
banner states, invoice detail + PDF download, admin PDF button, adjust-validity
POST, and plan-form negative-price rejection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@iammukeshm iammukeshm merged commit dae5c76 into main May 28, 2026
12 checks passed
@iammukeshm iammukeshm deleted the feat/tenant-activation-confirm branch May 28, 2026 17:42
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send {Context} email to {Email}", context, email);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants