fullstackhero/dotnet-starter-kit · error · NotFoundException

Invoice not found.

Error message

Invoice {invoiceId} not found.

What it means

LoadInvoiceAsync queries Invoices with (isRoot || i.TenantId == callerTenantId) and Id match. If no invoice satisfies both, NotFoundException("Invoice {id} not found.") is thrown — including the deliberate case where a tenant asks for another tenant's invoice (returned as 404 to avoid leaking existence).

Solutions

  1. Verify the invoiceId exists and belongs to the caller's tenant (query as root to confirm).
  2. As a tenant, only operate on invoice Ids returned from your own tenant's invoice list.
  3. Check environment/connection-string consistency between the caller and the database.
  4. Treat the 404 for cross-tenant Ids as expected security behavior, not a bug.

Example fix

// before
await billing.VoidInvoiceAsync(otherTenantInvoiceId, ...); // 404
// after
var inv = await db.Invoices.FirstOrDefaultAsync(i => i.Id == id && i.TenantId == callerTenantId);
if (inv is null) throw new NotFoundException($"Invoice {id} not found.");
Defensive patterns

Strategy: try-catch

Validate before calling

var exists = await db.Invoices.AnyAsync(i => i.Id == id && (isRoot || i.TenantId == callerTenantId));
if (!exists) return NotFound($"Invoice {id} not found.");

Try / catch

try { await billing.VoidInvoiceAsync(id, ct); }
catch (NotFoundException) { /* 404 — wrong id, deleted, or cross-tenant */ }

Prevention

When it happens

Trigger: Issue/MarkPaid/Void called with an invoiceId that doesn't exist, was deleted, belongs to another tenant (as non-root caller), or the caller is not root and the Id came from a different environment.

Common situations: Webhook referencing an invoice from a different environment (staging vs prod); tenant user reusing a root-visible invoice Id; stale UI after invoice void/deletion; connection string drift.

Understand the failure class

Background: Record Not Found Errors: "not found", RecordNotFound, and "was not found" — what they mean and how to fix them — this error's family across 28 libraries.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Services/BillingService.cs:298

    public async Task VoidInvoiceAsync(Guid invoiceId, string? reason, CancellationToken cancellationToken = default)
    {
        var invoice = await LoadInvoiceAsync(invoiceId, cancellationToken).ConfigureAwait(false);
        invoice.Void(reason);
        await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
    }

    // Issue/MarkPaid/Void load here. BillingDbContext isn't tenant-filtered, so scope to caller: root
    // mutates any invoice; a tenant caller is pinned to its own (cross-tenant id → 404, can't mutate).
    private async Task<Invoice> LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken)
    {
        var callerTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id
            ?? throw new UnauthorizedException("Tenant context is required.");
        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;

        return await _db.Invoices
            .FirstOrDefaultAsync(i => i.Id == invoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken)
            .ConfigureAwait(false)
            ?? throw new NotFoundException($"Invoice {invoiceId} not found.");
    }

    public async Task<Invoice?> CreateSubscriptionInvoiceAsync(
        string tenantId,
        Guid planId,
        DateTime periodStartUtc,
        DateTime periodEndUtc,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);

        var plan = await _db.Plans.FirstOrDefaultAsync(p => p.Id == planId, cancellationToken).ConfigureAwait(false)
            ?? throw new NotFoundException($"Plan {planId} not found for tenant {tenantId}.");

        var termPrice = plan.TermPrice;
        if (termPrice.Amount <= 0m)
        {
            // Free / trial plan — validity is still set, but there is nothing to bill.

View on GitHub (pinned to 3f2959e683)