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
- Verify the invoiceId exists and belongs to the caller's tenant (query as root to confirm).
- As a tenant, only operate on invoice Ids returned from your own tenant's invoice list.
- Check environment/connection-string consistency between the caller and the database.
- 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
- Only use invoice Ids returned by your own tenant's list endpoints.
- Confirm environment/connection-string consistency before webhooks.
- Expect 404 (not 403) for cross-tenant invoice Ids; don't treat it as a leak.
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
- Invoice not found.
- Invoice not found.
- Tenant context is required.
- Only the root operator may generate invoices across tenants.
- Tenant context is required.
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)