superpowers/plans/2026-05-28-tenant-billing-production.md
For agentic workers: execute task-by-task with TDD (red→green→commit). Steps use checkbox syntax. Spec:
superpowers/specs/2026-05-28-tenant-billing-production-hardening-design.md. Read it first.
Goal: Complete the tenant-billing SaaS feature to production grade — backend hardening, expiry/renewal notifications, PDF invoices, tenant-facing dashboard (view + warnings), and full regression/integration tests.
Architecture: Invoice-as-record (no gateway), no proration, event-driven side effects via in-memory
IEventBus, daily Hangfire scan for expiry notifications, on-demand QuestPDF behind IInvoicePdfRenderer.
Tech Stack: .NET 10, EF Core 10, Mediator, FluentValidation, Hangfire, QuestPDF, xUnit + Shouldly + NSubstitute + Testcontainers, React 19 + Vite + TanStack Query + Playwright.
Conventions (every task): handlers public sealed, ValueTask<T>, .ConfigureAwait(false) on awaits,
propagate CancellationToken, structured logging, TreatWarningsAsErrors. Build clean + green tests
before each commit. Verify current code before writing a test (the audit had wrong claims — e.g. indexes
already exist).
Files: Modify src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantService.cs:203.
Multitenancy.Tests (or integration) test that renewal stacking uses the injected clock:
with a FakeTimeProvider set to T, renew a tenant whose ValidUpto < T ⇒ new ValidUpto == T + term.var now = DateTime.UtcNow; with var now = _timeProvider.GetUtcNow().UtcDateTime;.fix(billing): use injected TimeProvider in TenantService.RenewAsync.X-Subscription-Grace response headerFiles: Modify MultitenancyModule.cs grace branch (~lines 194-202).
ValidUpto < now <= ValidUpto+grace gets a 2xx with header
X-Subscription-Grace = ceil(days left); an Active tenant has no header; past grace ⇒ 403 (unchanged).await next, when nowUtc <= ValidUpto+grace && nowUtc > ValidUpto, set
ctx.Response.Headers["X-Subscription-Grace"] = ((int)Math.Ceiling((tenant.ValidUpto.AddDays(graceDays) - nowUtc).TotalDays)).ToString(CultureInfo.InvariantCulture).
Use ctx.Response.OnStarting if headers risk being sent — simplest: set before calling next (guard endpoints don't write before).feat(billing): emit X-Subscription-Grace header during grace window.AdjustTenantValidityCommand (operator override)Files:
…Contracts/v1/AdjustTenantValidity/AdjustTenantValidityCommand.cs + …Response.cs.…Multitenancy/Features/v1/AdjustTenantValidity/{Handler,Validator,Endpoint}.cs.ITenantService.AdjustValidityAsync + impl in TenantService.cs.MultitenancyModule.MapEndpoints.Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs.Contract: record AdjustTenantValidityCommand(string TenantId, DateTime ValidUpto) : ICommand<AdjustTenantValidityCommandResponse>;
Response: record AdjustTenantValidityCommandResponse(string TenantId, DateTime ValidUpto);
Service: Task<DateTime> AdjustValidityAsync(string id, DateTime validUpto, CancellationToken ct) — load tenant,
tenant.ValidUpto = DateTime.SpecifyKind(validUpto, DateTimeKind.Utc) (direct set ⇒ allows backdating for
comps/immediate-expire), UpdateAsync + RefreshTenantCacheAsync, log [Multitenancy] adjusted validity…. No event, no invoice.
Endpoint: POST /{id}/adjust-validity, RequirePermission(MultitenancyPermissions.Tenants.UpgradeSubscription)
(reuse — root-only operator validity action), body/route id match guard like RenewTenant.
Validator: TenantId NotEmpty; ValidUpto must be a real date (not default).
feat(billing): operator AdjustTenantValidity override (no invoice).Files: Integration.Tests/Tests/Multitenancy/*, Integration.Tests/Tests/Billing/*, Billing.Tests/*.
For each, first read the existing test file to confirm it's missing, then add only the gap:
TenantService.GetStatusAsync (unit, FakeTimeProvider): now==ValidUpto⇒Active,
now==graceEnds⇒InGrace, now==graceEnds+1s⇒Expired, now==ValidUpto+1s⇒InGrace.ux_subscriptions_tenantid_active.TenantSubscribedIntegrationEvent Id ⇒ one subscription + one invoice.BillingDbInitializer seeds free/pro/pro-annual w/ correct interval/price; idempotent on re-run.test(billing): ….Phase A gate: dotnet build src/FSH.Starter.slnx clean; dotnet test Billing+Multitenancy+Integration green.
Multitenancy.Contracts/Events/: TenantNearingExpiryIntegrationEvent (+int DaysRemaining),
TenantEnteredGraceIntegrationEvent, TenantExpiredIntegrationEvent. Fields mirror
TenantSubscribedIntegrationEvent + TenantName, AdminEmail, PlanKey, ValidUpto, GraceEndsUtc.Billing.Contracts/Events/: InvoiceIssuedIntegrationEvent(Id, OccurredOnUtc, TenantId, CorrelationId, Source, InvoiceId, InvoiceNumber, decimal Amount, string Currency, DateTime? DueAtUtc, int PeriodYear, int PeriodMonth).feat(billing): add expiry + invoice-issued integration events.InvoiceIssued from the subscription-invoice handlersBillingService.CreateSubscriptionInvoiceAsync (or the two integration-event handlers after issue),
publish InvoiceIssuedIntegrationEvent via the same IEventBus already injected. Only when an invoice
was actually issued (skip free/zero-price). Integration test asserts publish on subscription invoice.TenantExpiryNotice dedup entity in TenantDbContextModules.Multitenancy/Domain/TenantExpiryNotice.cs (Id, TenantId, NoticeType (string/enum), ValidUptoUtc, CreatedAtUtc), EF config with unique (TenantId, NoticeType, ValidUptoUtc), DbSet on TenantDbContext.FSH.Starter.Migrations.PostgreSQL (tenant schema). Full-build before migrations add.TenantExpiryScanJob (Multitenancy)Services/TenantExpiryScanJob.cs RunAsync(CancellationToken); inject IMultiTenantStore<AppTenantInfo>,
IEventBus, IOptions<TenantBillingOptions>, TenantDbContext, TimeProvider, ILogger. For each active
non-root tenant compute state; check-or-insert TenantExpiryNotice; publish the one matching event; per-tenant try/catch.MultitenancyModule.MapEndpoints: IRecurringJobManager.AddOrUpdate("tenant-expiry-scan", Job.FromExpression<TenantExpiryScanJob>(j => j.RunAsync(CancellationToken.None)), "0 2 * * *", new RecurringJobOptions{TimeZone=TimeZoneInfo.Utc}).TenantBillingOptions.ExpiryNotificationLeadDays = 7.IEventBus): correct event per state incl. boundaries; root/inactive excluded.
Integration: run twice ⇒ dedup prevents second publish.feat(billing): daily tenant expiry scan job + dedup ledger.Modules.Notifications/IntegrationEventHandlers/{Nearing,EnteredGrace,Expired,InvoiceIssued}EmailHandler.cs
(sealed, IIntegrationEventHandler<T>), inject IMailService + ILogger. Build MailRequest via private
BillingEmailBodies helper (subject + HTML). try/catch + warn-log (never throw).Modules.Notifications references Multitenancy.Contracts + Billing.Contracts.IMailService.SendAsync received (fake/substitute mail service in the factory).feat(notifications): email tenant on expiry states + invoice issued.Phase B gate: build clean; tests green; dotnet run --project DbMigrator -- apply works locally.
IInvoicePdfRenderer + QuestPDF implModules.Billing; set QuestPDF.Settings.License = LicenseType.Community at module init.Services/IInvoicePdfRenderer.cs byte[] Render(InvoiceDto invoice); Services/InvoicePdfRenderer.cs
builds A4: header (number/status/dates), bill-to tenant, period, line-items table, subtotal, notes; 2dp currency.%PDF-prefixed bytes for a representative InvoiceDto.feat(billing): on-demand invoice PDF renderer (QuestPDF).GET /api/v1/billing/invoices/{id}/pdf (operator, Billing.View) and GET …/invoices/me/{id}/pdf
(tenant-self, 404 cross-tenant). Return Results.File(bytes, "application/pdf", $"{number}.pdf").feat(billing): invoice PDF download endpoints.Phase C gate: build clean; tests green.
tenants/me/status endpoint (backend)GET /api/v1/tenants/me/status resolving the calling tenant from context; returns trimmed status
(plan, validUpto, expiryState, graceEndsUtc). Any authenticated tenant user. Integration test + 401 unauth.SubscriptionStatus to Active|Suspended|Cancelled; type invoice line items; add
getMyStatus() + getMyInvoice(id) + invoicePdfUrl(id)./subscription page (plan, validity, expiry badge, usage with limits/overage, recent invoices), nav entry, lazy route.getMyStatus (staleTime ~5m); shows for InGrace + within lead days.feat(dashboard): tenant subscription page + expiry banners + invoice PDF.invoices/{id}/pdf).POST /tenants/{id}/adjust-validity), root-gated.feat(admin): invoice PDF download + adjust-validity + plan-form validation.Phase D gate: npm run build + npx playwright test green in both apps (route-mocked).
dotnet build src/FSH.Starter.slnx (TreatWarningsAsErrors) + full dotnet test (Docker up).C:\Users\mukesh\repos\fullstackhero\docs) + changelog (golden rule #10): new config key,
new endpoints, QuestPDF license note, dashboard subscription page + banners, expiry/renewal emails.project_tenant_billing_lifecycle.md (Phases 3–4 + dashboard + tests done).