{"record":{"id":"dbe3b8bc1dac520d","repo":"fullstackhero/dotnet-starter-kit","slug":"cannot-mark-invoice-as-paid-from-status-status","errorCode":null,"errorMessage":"Cannot mark invoice as paid from status {Status}.","messagePattern":"Cannot mark invoice as paid from status (.+?)\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/Modules/Billing/Modules.Billing/Domain/Invoice.cs","lineNumber":140,"sourceCode":"    public void Issue(DateTime? dueAtUtc = null)\n    {\n        RequireStatus(InvoiceStatus.Draft);\n        Status = InvoiceStatus.Issued;\n        IssuedAtUtc = DateTime.UtcNow;\n        DueAtUtc = dueAtUtc is null\n            ? IssuedAtUtc.Value.AddDays(14)\n            : DateTime.SpecifyKind(dueAtUtc.Value, DateTimeKind.Utc);\n    }\n\n    public void MarkPaid()\n    {\n        if (Status is InvoiceStatus.Paid)\n        {\n            return;\n        }\n        if (Status is not InvoiceStatus.Issued)\n        {\n            throw new InvalidOperationException($\"Cannot mark invoice as paid from status {Status}.\");\n        }\n        Status = InvoiceStatus.Paid;\n        PaidAtUtc = DateTime.UtcNow;\n    }\n\n    public void Void(string? reason = null)\n    {\n        if (Status is InvoiceStatus.Paid)\n        {\n            throw new InvalidOperationException(\"Paid invoices cannot be voided.\");\n        }\n        if (Status is InvoiceStatus.Void)\n        {\n            // Idempotent: re-voiding must not re-stamp VoidedAtUtc or append the reason again.\n            return;\n        }\n        Status = InvoiceStatus.Void;\n        VoidedAtUtc = DateTime.UtcNow;","sourceCodeStart":122,"sourceCodeEnd":158,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs#L122-L158","documentation":"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.","triggerScenarios":"Calling MarkPaid on a Draft invoice (never issued); on a Voided invoice; or after a concurrent handler already moved it past Issued.","commonSituations":"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.","solutions":["Call Issue() before MarkPaid so the invoice reaches Issued state first.","Guard with a status check before invoking MarkPaid.","If voided, do not mark paid — issue a corrected invoice instead.","Reload the invoice from the database to get its current status before transitioning."],"exampleFix":"// before\ninvoice.MarkPaid(); // throws from Draft\n\n// after\ninvoice.AddLineItem(...);\ninvoice.Issue();\nif (invoice.Status == InvoiceStatus.Issued)\n{\n    invoice.MarkPaid();\n}","handlingStrategy":"validation","validationCode":"if (invoice.Status is not InvoiceStatus.Issued and not InvoiceStatus.Paid) throw new InvalidOperationException($\"Cannot mark paid from {invoice.Status}\");","typeGuard":null,"tryCatchPattern":"try { invoice.MarkPaid(); } catch (InvalidOperationException ex) { logger.LogWarning(ex, \"Invalid invoice transition\"); }","preventionTips":["Always Issue() before MarkPaid()","Re-read the entity from the DB before state transitions in long-running flows","Make payment handlers idempotent (skip when already Paid)"],"tags":["domain","state-machine","billing","invoice"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}