Skip to content

Keep e-mailed invoices in Downloads and delete them with the e-mail - #1010

Merged
KrzysztofPajak merged 6 commits into
developfrom
feature/email-invoice
Nov 1, 2026
Merged

KrzysztofPajak merged 6 commits into
developfrom
feature/email-invoice

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Type: bugfix

Issue

The order placed, paid and completed e-mails can attach a PDF invoice. By default the invoice was written as a file to wwwroot/assets/files/exportimport. With AttachPdfInvoiceToBinary on it went to Downloads instead, and the order completed e-mail then produced both.

Neither copy was ever removed: not when the e-mail was deleted, not when all e-mails were cleared, and not when a customer deleted their account. An invoice holds customer data, so it should live in the application's file storage and go away with its e-mail.

Solution

  • Always Downloads. The three handlers use SaveOrderToBinary. PrintOrderToPdf and the AttachPdfInvoiceToBinary setting are removed.
  • New field QueuedEmail.InvoiceDownloadId. It marks the one download an e-mail owns. AttachedDownloads cannot do this, because it also holds the message template's attachment, which many e-mails share. The send task adds the invoice to the e-mail's attachments.
  • Deleted with the e-mail. QueuedEmailService deletes the invoice when one e-mail is deleted, when selected e-mails are deleted, in DeleteAllEmails and in DeleteCustomerEmail. The invoice goes first, so a delete that is interrupted can be repeated. Only the e-mails read at the start are deleted with their invoices, so an e-mail queued in the meantime keeps its invoice and stays in the queue. A file that cannot be deleted is logged and does not stop the rest or the account deletion.
  • Deleted with the customer. A handler on EntityDeleted<Customer> deletes the invoice e-mails of that customer's orders through DeleteOrderInvoiceEmails. It reads only order ids and includes deleted orders. An invoice goes to the order's billing address, which may not be the account's e-mail, and only My account calls DeleteCustomerEmail. The handler also covers an admin, a company owner, the API and OIDC deleting a customer. Other e-mails of a customer deleted that way stay in the queue, as they did before.
  • Requeue makes a copy through CopyInvoiceDownload, so each e-mail owns its download and no reference counting is needed. The requeued e-mail keeps the order reference, so it is found when the customer is deleted.
  • No e-mail, no invoice. When no e-mail is queued (inactive template, no recipient, or an error while building or queuing it), OrderMessageProviderService deletes the invoice it was given. A file that cannot be deleted there is logged. InsertQueuedEmail also deletes the invoice of an e-mail it could not store, which covers the copy made by a requeue. One limit: if a plugin's EntityInserted<QueuedEmail> handler throws after the e-mail is stored, the order message path still deletes the invoice, and that e-mail goes out without it. No handler like that exists in this repository.
  • An invoice does not stop the order. When the invoice cannot be generated, the paid and completed handlers log it and send the e-mail without it, as the placed handler already did. The store owner and vendor messages, loyalty points and gift vouchers still run.

The new queries read only ids. The ones in DeleteAllEmails and DeleteCustomerEmail scan what the delete next to them removes anyway, and DeleteOrderInvoiceEmails runs only when a customer is deleted, so there is no new index. The invoice downloads have no DownloadGuid, so the storefront download action does not serve them.

No new resources, settings or migrations. The unused resource Admin.Settings.Order.AttachPdfInvoiceToBinary is removed from DefaultLanguage.xml.

Breaking changes

For plugins and custom code, all of which need a rebuild:

  • IPdfService.PrintOrderToPdf is removed, and PdfService no longer takes IWebHostEnvironment.
  • IOrderMessageProviderService: SendOrderPlacedCustomerMessage, SendOrderPaidCustomerMessage and SendOrderCompletedCustomerMessage take invoiceDownloadId instead of attachmentFilePath, attachmentFileName and attachments.
  • IMessageProviderService.SendNotification has a new optional parameter at the end, so a custom implementation must add it.
  • IQueuedEmailService has new members CopyInvoiceDownload and DeleteOrderInvoiceEmails, and IDownloadService has DeleteDownloadById. The QueuedEmailService, OrderMessageProviderService, OrderStatusCommandHandler and ProcessOrderPaidCommandHandler constructors changed.
  • OrderSettings.AttachPdfInvoiceToBinary and its model property are removed. A stored value is ignored on load and dropped on the next save.

Upgrading in place:

  • E-mails queued before the upgrade keep working. QueuedEmail.AttachmentFilePath and the file branch of EmailSender stay, so they still read their files.
  • Files already in wwwroot/assets/files/exportimport are not touched. Remove the order_*.pdf files once those e-mails have been sent.
  • Invoices stored earlier through AttachedDownloads stay when their e-mail is deleted, because they cannot be told apart from template attachments.

Testing

Unit tests pass: Grand.Business.Messages.Tests (108), Grand.Business.Storage.Tests (81), Grand.Business.Checkout.Tests (499), Grand.Business.Common.Tests (171), Grand.Modules.Tests (346), Grand.Mapping.Tests (237). The full solution builds. The other test projects were not run.

A throwaway program, not part of this PR, ran the real services against LiteDB and MongoDB: queue, send task with a fake sender, requeue copy, and the three kinds of delete, including an e-mail document without the new field. All checks passed on both. A second one ran DeleteOrderInvoiceEmails, DeleteCustomerEmail and DeleteAllEmails against both databases with a fake download service, including an e-mail queued while the invoices were being deleted, which kept its invoice. On MongoDB it also covered a document without the new field, and the event handler itself: invoices of the customer's live and deleted orders went, another customer's stayed. Real SMTP delivery, the event being raised in a running application and the admin screens below were not run.

  1. In Admin > Settings > Sales > Order settings, turn on the PDF invoice for the order placed e-mail. The "Attach PDF invoice to binary" option is gone, also in the store panel.
  2. Place an order and let the queued e-mail send. It arrives with the invoice, and no new file appears in wwwroot/assets/files/exportimport. The QueuedEmail document has InvoiceDownloadId, pointing at a Download document.
  3. Add an attachment to that message template and place another order. The e-mail carries both files, and InvoiceDownloadId holds only the invoice.
  4. In System > Queued emails, open the e-mail and requeue it. The new e-mail has a different InvoiceDownloadId, the same Reference and ObjectId, and sends with the invoice.
  5. Delete the original e-mail. Its Download document and file are gone, while the requeued e-mail's invoice and the template attachment remain.
  6. Use "Delete all". Every invoice download is gone and the template attachment remains.
  7. Sign in as a customer with an invoice e-mail and delete the account from My account. That customer's invoices are gone and other customers' invoices remain.
  8. Place an order with a billing e-mail different from the account's, then delete that customer in Admin > Customers. The invoice e-mail and its Download document are gone.
  9. Deactivate the message template and place an order. No e-mail is queued and no new Download document remains.

An invoice holds customer data, so it belongs in the application's file
storage, with a lifetime tied to the e-mail it was generated for. Until now
it depended on a setting, and neither kind of copy was ever removed.

AttachedDownloads cannot carry this: it also holds the message template's
attachment, which many e-mails share. InvoiceDownloadId marks the one
download an e-mail owns, so deleting the e-mail can delete it safely. A
requeued e-mail gets its own copy for the same reason.
Copilot AI balanced review requested due to automatic review settings November 1, 2026 17:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Queue-deletion races can orphan PDFs containing customer data, while unhandled invoice-cleanup failures can block loyalty-point awards and gift-voucher activation after an order is marked Complete.

4 open issues
What changed in this PR

This PR moves emailed order invoices into Downloads and ties each invoice to its queued email, while keeping shared template attachments separate.

Changes:

  • Uses Downloads for placed, paid, and completed order invoices; removes the file-generation API and binary-storage setting.
  • Adds invoice ownership, attachment delivery, requeue copying, and cleanup when emails are deleted or not queued.
  • Adds lifecycle tests and updates settings snapshots and messaging documentation.

Validation here was limited to source inspection; builds and tests were not run.

File Description
src/​Web/​Grand.Web.Store/​Areas/​Store/​Views/​Setting/​Partials/​Sales.TabOrderSettings.cshtml Removes the binary-storage option from Store settings.
src/​Web/​Grand.Web.AdminShared/​Models/​Settings/​SalesSettingsModel.cs Removes the obsolete settings property.
src/​Web/​Grand.Web.Admin/​Controllers/​QueuedEmailController.cs Copies the invoice when requeuing an email.
src/​Web/​Grand.Web.Admin/​Areas/​Admin/​Views/​Setting/​Partials/​Sales.TabOrderSettings.cshtml Removes the binary-storage option from Admin settings.
src/​Tests/​Grand.Modules.Tests/​Services/​BackgroundService/​QueuedMessagesSendScheduleTaskTests.cs Tests invoice and template attachment delivery.
src/​Tests/​Grand.Mapping.Tests/​AdminShared/​SystemMappingTests.cs Tests preservation of invoice ownership during edits.
src/​Tests/​Grand.Mapping.Tests/​AdminShared/​SettingsMappingTests.OrderSettingsModel_ToDomain.verified.txt Updates the domain settings snapshot.
src/​Tests/​Grand.Mapping.Tests/​AdminShared/​SettingsMappingTests.OrderSettings_ToModel.verified.txt Updates the settings model snapshot.
src/​Tests/​Grand.Business.Storage.Tests/​Services/​DownloadServiceTests.cs Tests download deletion by ID.
src/​Tests/​Grand.Business.Messages.Tests/​Services/​QueuedEmailServiceTests.cs Tests invoice cleanup and independent copies.
src/​Tests/​Grand.Business.Messages.Tests/​Services/​MessageProviderServiceTest.cs Tests ownership and cleanup for unqueued notifications.
src/​Tests/​Grand.Business.Common.Tests/​Services/​Pdf/​PdfServiceTests.cs Tests PDF storage as a non-public download.
src/​Tests/​Grand.Business.Checkout.Tests/​Commands/​Handlers/​Orders/​ProcessOrderPaidCommandHandlerTests.cs Tests paid-order invoice attachment handling.
src/​Tests/​Grand.Business.Checkout.Tests/​Commands/​Handlers/​Orders/​OrderStatusCommandHandlerTests.cs Tests completed-order invoice handling.
src/​Tests/​Grand.Business.Checkout.Tests/​Commands/​Handlers/​Orders/​OrderNotificationCommandHandlerTests.cs Tests placed-order invoices and generation failures.
src/​Modules/​Grand.Module.ScheduledTasks/​BackgroundServices/​QueuedMessagesSendScheduleTask.cs Includes the owned invoice in outgoing attachments.
src/​Core/​Grand.Domain/​Orders/​OrderSettings.cs Removes the binary-storage setting.
src/​Core/​Grand.Domain/​Messages/​QueuedEmail.cs Adds the owned invoice download ID.
src/​Business/​Grand.Business.Storage/​Services/​DownloadService.cs Deletes downloads by ID without loading file contents.
src/​Business/​Grand.Business.Messages/​Services/​QueuedEmailService.cs Adds invoice copying and deletion cleanup.
src/​Business/​Grand.Business.Messages/​Services/​OrderMessageProviderService.cs Passes invoice ownership and cleans up unqueued invoices.
src/​Business/​Grand.Business.Messages/​Services/​MessageProviderService.cs Stores invoice ownership on queued emails.
src/​Business/​Grand.Business.Core/​Interfaces/​Storage/​IDownloadService.cs Exposes deletion by download ID.
src/​Business/​Grand.Business.Core/​Interfaces/​Messages/​IQueuedEmailService.cs Exposes invoice copying and documents cleanup.
src/​Business/​Grand.Business.Core/​Interfaces/​Messages/​IOrderMessageProviderService.cs Replaces invoice attachment arguments with a download ID.
src/​Business/​Grand.Business.Core/​Interfaces/​Messages/​IMessageProviderService.cs Adds the optional invoice download ID.
src/​Business/​Grand.Business.Core/​Interfaces/​Common/​Pdf/​IPdfService.cs Removes the file-based invoice API.
src/​Business/​Grand.Business.Common/​Services/​Pdf/​PdfService.cs Removes invoice file generation and hosting dependency.
src/​Business/​Grand.Business.Checkout/​Commands/​Handlers/​Orders/​ProcessOrderPaidCommandHandler.cs Saves paid-order invoices to Downloads.
src/​Business/​Grand.Business.Checkout/​Commands/​Handlers/​Orders/​OrderStatusCommandHandler.cs Saves completed-order invoices to Downloads.
src/​Business/​Grand.Business.Checkout/​Commands/​Handlers/​Orders/​OrderNotificationCommandHandler.cs Saves placed-order invoices to Downloads.
.ai/​skills/​message-notification.md Documents invoice ownership and the updated API.

Review effort: Balanced

Comment on lines +80 to +83
catch (Exception ex)
{
_logger.LogError(ex, "Error - order completed attachment file {OrderOrderNumber}", request.Order.OrderNumber);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. This catch is a deliberate best-effort boundary: PDF composition and file storage fail with many unrelated exception types, and none of them may stop the surrounding work. The exception is logged, and the same pattern is already used in OrderNotificationCommandHandler and across the business layer.

Comment on lines +75 to +78
catch (Exception ex)
{
_logger.LogError(ex, "Error - order paid attachment file {OrderOrderNumber}", order.OrderNumber);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. This catch is a deliberate best-effort boundary: PDF composition and file storage fail with many unrelated exception types, and none of them may stop the surrounding work. The exception is logged, and the same pattern is already used in OrderNotificationCommandHandler and across the business layer.

Comment on lines +253 to +257
catch (Exception ex)
{
//a broken file must not stop the rest of the order processing
_logger.LogError(ex, "Error deleting the invoice download {InvoiceDownloadId}", invoiceDownloadId);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. This catch is a deliberate best-effort boundary: PDF composition and file storage fail with many unrelated exception types, and none of them may stop the surrounding work. The exception is logged, and the same pattern is already used in OrderNotificationCommandHandler and across the business layer.

Comment on lines +260 to +264
catch (Exception ex)
{
//a broken file must not stop the rest nor the deletion of an email or an account
_logger.LogError(ex, "Error deleting the invoice download {InvoiceDownloadId}", invoiceDownloadId);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. This catch is a deliberate best-effort boundary: PDF composition and file storage fail with many unrelated exception types, and none of them may stop the surrounding work. The exception is logged, and the same pattern is already used in OrderNotificationCommandHandler and across the business layer.

@KrzysztofPajak
KrzysztofPajak requested a balanced review from Copilot November 1, 2026 18:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Approval is blocked by unresolved correctness and reliability issues in customer-deletion cleanup, including missed soft-deleted orders, excessive order materialization, cross-customer invoice deletion, and orphaned copied downloads on insertion failure.

5 open issues
2 resolved since last review

Review effort: Lite

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two moderate active findings leave uploaded invoice files orphaned when metadata insertion or bucket deletion fails, so the change is not approval-ready.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Clean up copied invoice files when metadata insertion fails

src/​Business/​Grand.Business.Messages/​Services/​QueuedEmailService.cs:109

The same upload-before-insert failure also applies to the requeue copy: InsertDownload can upload the copied bytes and then throw while inserting its metadata. In that case no copy id is returned, so Requeue never reaches InsertQueuedEmail and its rollback cannot run; the copied invoice is left unowned. Make CopyInvoiceDownload (or the underlying download insert) clean up the uploaded object when metadata insertion fails.

Medium severity Preserve download metadata when bucket deletion fails

src/​Business/​Grand.Business.Storage/​Services/​DownloadService.cs:145

DeleteDownloadById delegates to DeleteDownload, which removes the Download document before deleting its bucket object. DeleteInvoices catches a bucket-delete failure and then removes the queued email, so a transient storage failure (or a process interruption between these operations) loses the only object id and leaves the invoice file orphaned permanently. Delete the file before removing its metadata, or otherwise preserve/retry the object id when file deletion fails.

5 resolved since last review

Review effort: Lite

@KrzysztofPajak
KrzysztofPajak merged commit 5e8cf26 into develop Nov 1, 2026
10 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/email-invoice branch November 1, 2026 18:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants