fullstackhero/dotnet-starter-kit · error · InvalidOperationException

Cannot mark invoice as paid from status

Error message

Cannot mark invoice as paid from status {Status}.

What it means

Invoice.MarkPaid enforces the state machine: an invoice can only transition to Paid from Issued. Calling MarkPaid when Status is Draft, Void, or any other non-Issued state throws InvalidOperationException. Marking an already-Paid invoice is silently idempotent.

Solutions

  1. Call Issue() before MarkPaid so the invoice reaches Issued state first.
  2. Guard with a status check before invoking MarkPaid.
  3. If voided, do not mark paid — issue a corrected invoice instead.
  4. Reload the invoice from the database to get its current status before transitioning.

Example fix

// before
invoice.MarkPaid(); // throws from Draft

// after
invoice.AddLineItem(...);
invoice.Issue();
if (invoice.Status == InvoiceStatus.Issued)
{
    invoice.MarkPaid();
}
Defensive patterns

Strategy: validation

Validate before calling

if (invoice.Status is not InvoiceStatus.Issued and not InvoiceStatus.Paid) throw new InvalidOperationException($"Cannot mark paid from {invoice.Status}");

Try / catch

try { invoice.MarkPaid(); } catch (InvalidOperationException ex) { logger.LogWarning(ex, "Invalid invoice transition"); }

Prevention

When it happens

Trigger: Calling MarkPaid on a Draft invoice (never issued); on a Voided invoice; or after a concurrent handler already moved it past Issued.

Common situations: Payment webhooks firing for invoices that were voided between order and payment; batch payment jobs processing stale invoice snapshots; tests reusing a saved entity instance.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Domain/Invoice.cs:140

    public void Issue(DateTime? dueAtUtc = null)
    {
        RequireStatus(InvoiceStatus.Draft);
        Status = InvoiceStatus.Issued;
        IssuedAtUtc = DateTime.UtcNow;
        DueAtUtc = dueAtUtc is null
            ? IssuedAtUtc.Value.AddDays(14)
            : DateTime.SpecifyKind(dueAtUtc.Value, DateTimeKind.Utc);
    }

    public void MarkPaid()
    {
        if (Status is InvoiceStatus.Paid)
        {
            return;
        }
        if (Status is not InvoiceStatus.Issued)
        {
            throw new InvalidOperationException($"Cannot mark invoice as paid from status {Status}.");
        }
        Status = InvoiceStatus.Paid;
        PaidAtUtc = DateTime.UtcNow;
    }

    public void Void(string? reason = null)
    {
        if (Status is InvoiceStatus.Paid)
        {
            throw new InvalidOperationException("Paid invoices cannot be voided.");
        }
        if (Status is InvoiceStatus.Void)
        {
            // Idempotent: re-voiding must not re-stamp VoidedAtUtc or append the reason again.
            return;
        }
        Status = InvoiceStatus.Void;
        VoidedAtUtc = DateTime.UtcNow;

View on GitHub (pinned to 3f2959e683)