fullstackhero/dotnet-starter-kit · error · NotFoundException

Invoice not found.

Error message

Invoice {query.InvoiceId} not found.

What it means

GetInvoiceByIdQueryHandler looks up the invoice by id and, when the caller is not root, adds a TenantId == callerTenantId filter to the otherwise unfiltered BillingDbContext query. If no invoice matches (wrong id, or the invoice belongs to another tenant), it throws NotFoundException("Invoice {id} not found."). Notably the same 404 is returned for cross-tenant ids so existence is never leaked.

Solutions

  1. Verify the invoice id exists and matches the intended tenant by querying the invoices table (SELECT * FROM Invoices WHERE Id = '<id>').
  2. If you are a tenant caller, confirm the invoice actually belongs to your tenant; cross-tenant reads require the root identity.
  3. Re-check the id source: re-list invoices via GET /invoices and use an id from that response rather than a hand-copied value.
  4. If root needs cross-tenant visibility, confirm the caller token is root (MultitenancyConstants.Root.Id) — non-root callers are pinned to their own TenantId.

Example fix

// before: stale/hand-copied id
await apiFetch(`/invoices/${invoiceIdFromTicket}`);

// after: resolve a fresh id from the tenant-scoped list
const { items } = await apiFetch('/invoices');
const target = items.find(i => i.invoiceNumber === 'INV-0042');
await apiFetch(`/invoices/${target.id}`);
Defensive patterns

Strategy: try-catch

Validate before calling

const res = await apiFetch('/invoices');
const exists = res.items.some(i => i.id === invoiceId);
if (!exists) throw new Error(`Invoice ${invoiceId} not visible to this tenant`);

Type guard

function isNotFound(e) { return e?.status === 404 || /not found/i.test(e?.message ?? ''); }

Try / catch

try { return await apiFetch(`/invoices/${id}`); }
catch (e) { if (isNotFound(e)) { return null; } throw e; }

Prevention

When it happens

Trigger: GET /invoices/{id} with an invoice id that does not exist; a tenant caller passing another tenant's invoice id (filtered out by TenantId predicate); a deleted or soft-removed invoice id; root passing a typo'd GUID.

Common situations: Frontend caching an invoice id from a different environment (staging vs prod database); copying an invoice id from a support ticket issued for another tenant; referencing an invoice before the assign/creation transaction committed; case/format-mangled id from a URL.

Understand the failure class

Background: 'Could not be found', 'does not exist', 'not found in database': the resource-not-found family when an ID, slug, key, or URI lookup comes back empty — this error's family across 20 libraries.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs:33

    : IQueryHandler<GetInvoiceByIdQuery, InvoiceDto>
{
    public async ValueTask<InvoiceDto> Handle(GetInvoiceByIdQuery query, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

        // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root
        // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler.
        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
            ?? throw new UnauthorizedException("Tenant context is required.");
        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;

        var invoice = await dbContext.Invoices.AsNoTracking()
            .Include(i => i.LineItems)
            .FirstOrDefaultAsync(
                i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId),
                cancellationToken)
            .ConfigureAwait(false)
            ?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.");

        return invoice.ToDto();
    }
}

View on GitHub (pinned to 3f2959e683)