fullstackhero/dotnet-starter-kit · error · NotFoundException

Active plan with key

Error message

Active plan with key '{query.PlanKey}' not found.

What it means

GetPlanTermQueryHandler normalizes the requested plan key to lowercase and looks up an ACTIVE plan (Key == key && IsActive) in BillingDbContext. If no active plan has that key it throws NotFoundException("Active plan with key '{key}' not found.") — this covers both a nonexistent key and a plan that exists but is deactivated.

Solutions

  1. List active plans (GET /plans or the Plans table: SELECT Key FROM Plans WHERE IsActive) and use an exact key from that list.
  2. Send the key in lowercase canonical slug form; the handler lowercases input but a key stored with different casing will never match.
  3. Seed the plan in the target environment via the DbMigrator (dotnet run --project src/Host/FSH.Starter.DbMigrator -- apply --seed).
  4. Reactivate the plan (IsActive = true) if it was disabled intentionally but terms are still being requested.

Example fix

// before
await apiFetch(`/plans/${'Pro'}/term`);

// after: canonical key from the plans list
const plans = await apiFetch('/plans');
const key = plans.find(p => p.name === 'Pro').key; // 'pro'
await apiFetch(`/plans/${key}/term`);
Defensive patterns

Strategy: validation

Validate before calling

const plans = await apiFetch('/plans');
const key = String(planKey).trim().toLowerCase();
if (!plans.some(p => p.key === key && p.isActive)) throw new Error(`Plan '${key}' is not an active plan`);

Type guard

function isActivePlan(p) { return typeof p?.key === 'string' && p.isActive === true; }

Try / catch

try { return await apiFetch(`/plans/${key}/term`); }
catch (e) { if (isNotFound(e)) { show('Plan unavailable'); return null; } throw e; }

Prevention

When it happens

Trigger: GET /plans/{planKey}/term with a key that is not a canonical lowercase slug (e.g. 'Pro', 'pro-annual ' with whitespace), a retired/renamed plan key, or a plan that has IsActive == false.

Common situations: Frontend hardcoding a plan key that was renamed during a pricing overhaul; environment drift (plan seeded in staging but not prod — run DbMigrator with --seed); trailing spaces or camel-case keys from marketing copy; requesting annual term for a plan that only defines monthly.

Understand the failure class

Background: "Not found" and "does not exist" errors: why "Task not found", "No such folder", and "Can't find" fire when a lookup comes back empty — this error's family across 14 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/300cb478ac8b1343. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Plans/GetPlanTerm/GetPlanTermQueryHandler.cs:21

using FSH.Modules.Billing.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Billing.Features.v1.Plans.GetPlanTerm;

public sealed class GetPlanTermQueryHandler(BillingDbContext dbContext)
    : IQueryHandler<GetPlanTermQuery, PlanTermResponse>
{
    public async ValueTask<PlanTermResponse> Handle(GetPlanTermQuery query, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

#pragma warning disable CA1308 // Plan keys are canonical lowercase slugs
        var key = query.PlanKey.ToLowerInvariant();
#pragma warning restore CA1308
        var plan = await dbContext.Plans.AsNoTracking()
            .FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false)
            ?? throw new NotFoundException($"Active plan with key '{query.PlanKey}' not found.");

        return new PlanTermResponse(
            plan.Id,
            plan.Key,
            plan.Name,
            plan.Interval,
            plan.TermMonths,
            plan.TermPrice.Amount,
            plan.Currency);
    }
}

View on GitHub (pinned to 3f2959e683)