{"record":{"id":"842ffc749b3a34d3","repo":"fullstackhero/dotnet-starter-kit","slug":"paid-invoices-cannot-be-voided","errorCode":null,"errorMessage":"Paid invoices cannot be voided.","messagePattern":"Paid invoices cannot be voided\\.","errorType":"exception","errorClass":"InvalidOperationException","httpStatus":null,"severity":"error","filePath":"src/Modules/Billing/Modules.Billing/Domain/Invoice.cs","lineNumber":150,"sourceCode":"    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;\n        if (!string.IsNullOrWhiteSpace(reason))\n        {\n            Notes = string.IsNullOrWhiteSpace(Notes) ? reason : $\"{Notes}; Voided: {reason}\";\n        }\n    }\n\n    public void SetNotes(string? notes)\n    {\n        Notes = notes;\n    }","sourceCodeStart":132,"sourceCodeEnd":168,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs#L132-L168","documentation":"Invoice.Void throws InvalidOperationException when the invoice is already Paid, because a paid invoice represents collected money and must not be voided. Re-voiding an already-Void invoice is allowed and idempotent.","triggerScenarios":"Calling Void() on an invoice whose Status is InvoiceStatus.Paid.","commonSituations":"Cancellation flows that unconditionally void invoices after payment succeeded; race between a payment job and a cancellation job; UI not disabling Void for paid invoices.","solutions":["Check invoice.Status != InvoiceStatus.Paid before calling Void().","For paid invoices that must be cancelled, implement a refund/credit-note flow instead of voiding.","Disable the Void action in the UI for paid invoices.","Handle the InvalidOperationException as a user-facing 'cannot cancel paid invoice' message."],"exampleFix":"// before\ninvoice.Void(reason); // throws if paid\n\n// after\nif (invoice.Status != InvoiceStatus.Paid)\n{\n    invoice.Void(reason);\n}","handlingStrategy":"validation","validationCode":"if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Void) return; // nothing to void / not voidable","typeGuard":null,"tryCatchPattern":"try { invoice.Void(reason); } catch (InvalidOperationException) { // route to refund/credit-note flow for paid invoices }","preventionTips":["Disable Void buttons for paid invoices","Treat paid-invoice cancellation as a refund workflow, not a void","Serialize cancellation and payment jobs per invoice"],"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"}