without preference, without rewrite conflicts
Anyone who rewrites the invoice logic or credit memo processing in Magento 2 completely via preference risks conflicts with every additional module and every core update. Plugins on InvoiceRepositoryInterface, CreditmemoManagementInterface, and ShipmentRepositoryInterface allow targeted extensions: custom numbering, approval workflows for credit memos, additional totals lines, and automatic invoice creation on shipment, without replacing the core logic.
Table of Contents
- 1. Why plugins instead of preferences for invoice logic
- 2. Invoice numbering and validation via plugin on InvoiceRepositoryInterface
- 3. Business rules for credit memos: around plugin on CreditmemoManagementInterface
- 4. Observers for side effects: ERP synchronization and audit log
- 5. Audit log entity via db_schema.xml
- 6. Custom totals collector for fee and discount lines
- 7. Automatic invoice creation on shipment
- 8. Deployment, sort order, and testability of plugins
- 9. Preference vs. plugin in direct comparison
- 10. Summary
- 11. FAQ
1. Why plugins instead of preferences for invoice logic
As soon as a Magento project has its own requirements for the invoice logic or the credit memo processing, many teams reflexively reach for a preference and rewrite the entire Magento\Sales\Model\Order\Invoice class or the complete CreditmemoManagement service. The problem: a preference replaces the whole class and makes the project incompatible with any other module that also rewrites the same class. On a Magento update or a third-party extension, the invoice logic then breaks without warning because two preferences overwrite each other. A plugin, that is an interceptor, instead attaches itself in a targeted way before, around, or after a public method of a service contract, without losing the original implementation.
For invoice logic and credit memo processing this means concretely: instead of rewriting InvoiceManagement entirely, an around plugin is registered on InvoiceManagementInterface::setCapture or InvoiceRepositoryInterface::save. Instead of replacing CreditmemoManagement, an around plugin is attached to CreditmemoManagementInterface::refund. This way, Magento's core logic stays untouched, multiple modules can act on the same service contract in parallel as long as the sortOrder is maintained cleanly, and a core update only affects the custom extension, not the entire invoice logic of the shop.
2. Invoice numbering and validation via plugin on InvoiceRepositoryInterface
A common project requirement: invoices should receive their own, project-specific invoice number in addition to the standard increment ID, for example to hand over to an accounting system. The clean way to do this is an around plugin on Magento\Sales\Api\InvoiceRepositoryInterface::save. Before the actual $proceed call persists the invoice, the plugin checks mandatory fields, throws a LocalizedException if the increment ID is missing, and sets a custom custom_invoice_number attribute via a dedicated number generator if needed. This generator is itself a service contract, so the numbering logic remains exchangeable instead of being hardcoded in the plugin.
The order matters: an around plugin on InvoiceRepositoryInterface::save only fires once the invoice has already been fully built but has not yet been written to the database. For validations that need to fire before the actual capture, for example checking whether a payment method allows invoice logic with partial payments at all, a plugin on InvoiceManagementInterface::setCapture is the right approach, because that is where it is still decided whether the invoice is captured online or offline. Both plugins together cover the typical validation cases around invoice creation without requiring a preference.
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<!-- Invoice: custom numbering and validation on save -->
<type name="Magento\Sales\Api\InvoiceRepositoryInterface">
<plugin name="mironsoft_invoice_numbering_validation" type="Mironsoft\SalesLogic\Plugin\InvoiceNumberingPlugin" sortOrder="10"/>
</type>
<!-- Invoice: capture mode validation before online/offline decision -->
<type name="Magento\Sales\Api\InvoiceManagementInterface">
<plugin name="mironsoft_invoice_capture_validation" type="Mironsoft\SalesLogic\Plugin\CaptureValidationPlugin" sortOrder="20"/>
</type>
<!-- Creditmemo: business rule enforcement on refund -->
<type name="Magento\Sales\Api\CreditmemoManagementInterface">
<plugin name="mironsoft_creditmemo_refund_guard" type="Mironsoft\SalesLogic\Plugin\RefundApprovalPlugin" sortOrder="10"/>
</type>
<!-- Shipment: automatic invoice creation -->
<type name="Magento\Sales\Api\ShipmentRepositoryInterface">
<plugin name="mironsoft_autoinvoice_on_shipment" type="Mironsoft\SalesLogic\Plugin\AutoInvoiceOnShipmentPlugin" sortOrder="10"/>
</type>
</config>
3. Business rules for credit memos: around plugin on CreditmemoManagementInterface
Credit memo processing in Magento runs through Magento\Sales\Api\CreditmemoManagementInterface::refund. This is exactly the place where business rules that go beyond plain Magento standard logic can be enforced: a threshold above which a credit memo requires manager approval, an automatic refund of loyalty points on a refund, or a block on certain payment methods where refunds must be processed manually in the payment provider's backend. An around plugin is the right choice here because it can intercept the entire call and abort with a LocalizedException on a rule violation, before Magento even communicates with the payment provider.
The approval checker in the following example is deliberately modeled as its own service contract interface, not as a method directly inside the plugin. That allows the approval logic to be tested independently and extended later, for example with a role-based check or a connection to an external approval workflow tool. For the automatic application of loyalty points on a credit memo, the same structure applies: after a successful $proceed() call, another small plugin or an observer credits the points based on the credit memo total.
<?php
declare(strict_types=1);
namespace Mironsoft\SalesLogic\Plugin;
use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\CreditmemoManagementInterface;
use Magento\Sales\Api\Data\CreditmemoInterface;
use Mironsoft\SalesLogic\Model\ApprovalCheckerInterface;
use Psr\Log\LoggerInterface;
/**
* Enforces manager approval for high-value credit memo refunds.
*/
class RefundApprovalPlugin
{
private const APPROVAL_THRESHOLD = 500.00;
/**
* @param ApprovalCheckerInterface $approvalChecker Service that checks pending approval flags
* @param LoggerInterface $logger Logger for blocked refund attempts
*/
public function __construct(
private readonly ApprovalCheckerInterface $approvalChecker,
private readonly LoggerInterface $logger
) {
}
/**
* Blocks the refund above the configured threshold unless a manager approval exists.
*
* @param CreditmemoManagementInterface $subject Intercepted service contract
* @param callable $proceed Original refund logic
* @param CreditmemoInterface $creditmemo Credit memo to be refunded
* @param bool $offlineRequested Whether an offline refund was requested
* @return bool
* @throws LocalizedException
*/
public function aroundRefund(
CreditmemoManagementInterface $subject,
callable $proceed,
CreditmemoInterface $creditmemo,
bool $offlineRequested = false
): bool {
$grandTotal = (float) $creditmemo->getGrandTotal();
if ($grandTotal > self::APPROVAL_THRESHOLD && !$this->approvalChecker->isApproved($creditmemo)) {
$this->logger->warning(sprintf(
'Refund blocked: credit memo for order %s exceeds threshold without approval',
(string) $creditmemo->getOrderId()
));
throw new LocalizedException(
__('Refunds above %1 require manager approval before processing.', self::APPROVAL_THRESHOLD)
);
}
return $proceed($creditmemo, $offlineRequested);
}
}
4. Observers for side effects: ERP synchronization and audit log
Not every requirement for the invoice logic is a rule that should change or block the flow. When only a side effect should be triggered after successful invoice or credit memo creation, for example notifying an ERP system or writing an audit log entry, an observer is the better choice than a plugin. The events sales_order_invoice_save_after and sales_order_creditmemo_save_after are fired by Magento after every successful save and provide the respective entity object via getEvent()->getData().
The decisive difference to a plugin: an observer can no longer prevent the flow, because the invoice or credit memo has already been saved at this point. This is intentional, because side effects such as ERP synchronization must never become the reason why an invoice fails to save due to a failed network call. In the following example, the observer wraps both the ERP notification and the writing of the audit log entry in a try/catch block, so that an error in the ERP call never affects the core logic of the invoice logic.
<?php
declare(strict_types=1);
namespace Mironsoft\SalesLogic\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Model\Order\Invoice;
use Mironsoft\SalesLogic\Api\AuditLogRepositoryInterface;
use Mironsoft\SalesLogic\Api\Data\AuditLogInterfaceFactory;
use Mironsoft\SalesLogic\Model\ErpNotifierInterface;
use Psr\Log\LoggerInterface;
/**
* Notifies the ERP system and writes an audit log entry after invoice creation.
*/
class InvoiceSaveAfterObserver implements ObserverInterface
{
/**
* @param ErpNotifierInterface $erpNotifier Service that pushes invoice data to the ERP
* @param AuditLogRepositoryInterface $auditLogRepository Repository for the audit log entity
* @param AuditLogInterfaceFactory $auditLogFactory Factory for the audit log data model
* @param LoggerInterface $logger Logger for side-effect failures
*/
public function __construct(
private readonly ErpNotifierInterface $erpNotifier,
private readonly AuditLogRepositoryInterface $auditLogRepository,
private readonly AuditLogInterfaceFactory $auditLogFactory,
private readonly LoggerInterface $logger
) {
}
/**
* Executes the ERP notification and audit log write, isolated from the save transaction.
*
* @param Observer $observer Event observer carrying the invoice entity
* @return void
*/
public function execute(Observer $observer): void
{
/** @var Invoice $invoice */
$invoice = $observer->getEvent()->getData('invoice');
try {
$this->erpNotifier->notifyInvoiceCreated($invoice);
$auditLog = $this->auditLogFactory->create();
$auditLog->setOrderId((int) $invoice->getOrderId());
$auditLog->setEntityType('invoice');
$auditLog->setEntityIncrementId((string) $invoice->getIncrementId());
$auditLog->setEventType('created');
$auditLog->setPayload((string) $invoice->getGrandTotal());
$this->auditLogRepository->save($auditLog);
} catch (\Throwable $exception) {
$this->logger->error('ERP notification failed: ' . $exception->getMessage());
}
}
}
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_invoice_save_after">
<observer name="mironsoft_invoice_erp_audit" instance="Mironsoft\SalesLogic\Observer\InvoiceSaveAfterObserver"/>
</event>
<event name="sales_order_creditmemo_save_after">
<observer name="mironsoft_creditmemo_erp_audit" instance="Mironsoft\SalesLogic\Observer\CreditmemoSaveAfterObserver"/>
</event>
</config>
5. Audit log entity via db_schema.xml
For traceability in the invoice logic and credit memo processing, a dedicated audit log table is needed, in which every relevant action is recorded with a timestamp, entity type, and payload. Instead of an InstallSchema script, declarative schema is used consistently here: a db_schema.xml in the module defines the table, columns, the primary key, a foreign key to sales_order, and an index on the order ID. Declarative schema automatically generates the matching alter statements on changes and does away entirely with manually managed setup version numbers.
Access to this table happens exclusively through a dedicated repository following the service contract pattern: an AuditLogRepositoryInterface with save(), getById(), and getList(), plus an AuditLogInterface as the data model with getters and setters for each column. This keeps the observer from section 4 fully decoupled from the concrete persistence implementation and lets it be tested easily in unit tests with a mock of the repository, without needing a real database.
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_saleslogic_audit_log" resource="default" engine="innodb" comment="Sales Logic Audit Log">
<column xsi:type="int" name="entity_id" padding="10" unsigned="true" nullable="false" identity="true" comment="Entity ID"/>
<column xsi:type="int" name="order_id" padding="10" unsigned="true" nullable="false" comment="Order ID"/>
<column xsi:type="varchar" name="entity_type" nullable="false" length="32" comment="Entity Type (invoice/creditmemo)"/>
<column xsi:type="varchar" name="entity_increment_id" nullable="false" length="64" comment="Increment ID"/>
<column xsi:type="varchar" name="event_type" nullable="false" length="32" comment="Event Type"/>
<column xsi:type="text" name="payload" nullable="true" comment="Payload"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false" default="CURRENT_TIMESTAMP" comment="Created At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_SALESLOGIC_AUDIT_LOG_ORDER_ID_SALES_ORDER_ENTITY_ID"
table="mironsoft_saleslogic_audit_log" column="order_id"
referenceTable="sales_order" referenceColumn="entity_id" onDelete="CASCADE"/>
<index referenceId="MIRONSOFT_SALESLOGIC_AUDIT_LOG_ORDER_ID" indexType="btree">
<column name="order_id"/>
</index>
</table>
</schema>
6. Custom totals collector for fee and discount lines
A common but incorrect approach for additional fees or discounts on an invoice is directly manipulating getGrandTotal() and setGrandTotal() inside a plugin or observer. This works in the short term but breaks as soon as another module also modifies totals, because the order of interventions is undefined and totals can overwrite each other. The correct mechanism in Magento's invoice logic is a dedicated totals collector that extends Magento\Sales\Model\Order\Invoice\Total\AbstractTotal and is hooked into the existing totals calculation chain via sales.xml with a fixed sort_order.
The collect() method receives the complete invoice object, calculates the additional line through a dedicated service (here HandlingFeeCalculator), and adds the amount correctly to both grand_total and base_grand_total. Because the collector chain is managed by Magento itself, it does not matter in which order other modules register their own totals, as long as the sort_order is chosen sensibly. For credit memo totals, the same structure applies with Magento\Sales\Model\Order\Creditmemo\Total\AbstractTotal and a registration in the corresponding total_creditmemo node of sales.xml.
<?php
declare(strict_types=1);
namespace Mironsoft\SalesLogic\Model\Order\Invoice\Total;
use Magento\Sales\Model\Order\Invoice;
use Magento\Sales\Model\Order\Invoice\Total\AbstractTotal;
use Mironsoft\SalesLogic\Model\HandlingFeeCalculatorInterface;
/**
* Adds a custom handling fee line to the invoice totals.
*/
class HandlingFee extends AbstractTotal
{
/**
* @param HandlingFeeCalculatorInterface $calculator Calculates the fee for the given order
* @param array $data Additional constructor data forwarded to the parent
*/
public function __construct(
private readonly HandlingFeeCalculatorInterface $calculator,
array $data = []
) {
parent::__construct($data);
}
/**
* Collects and applies the handling fee to the invoice grand total.
*
* @param Invoice $invoice Invoice being totalled
* @return $this
*/
public function collect(Invoice $invoice): self
{
$fee = $this->calculator->calculateFor($invoice->getOrder());
if ($fee <= 0.0) {
return $this;
}
$invoice->setHandlingFee($fee);
$invoice->setBaseHandlingFee($fee);
$invoice->setGrandTotal((float) $invoice->getGrandTotal() + $fee);
$invoice->setBaseGrandTotal((float) $invoice->getBaseGrandTotal() + $fee);
return $this;
}
}
7. Automatic invoice creation on shipment
Many B2B projects need an invoice logic where an invoice is not created manually in the backend, but arises automatically as soon as a shipment is registered. An after plugin on Magento\Sales\Api\ShipmentRepositoryInterface::save is well suited for this. After the shipment has been saved successfully, the plugin checks whether the associated order allows an automatic invoice (for example based on the payment method or a customer group flag) and then calls InvoiceManagementInterface::prepareInvoice() as well as InvoiceRepositoryInterface::save() to create the invoice.
Important: the plugin should never block the shipment save itself if the invoice creation fails. An error in the automatic invoice logic must not cause an already shipped shipment to fail to save. That is why the invoice creation is wrapped in its own try/catch block, an error gets logged and possibly recorded via the audit log mechanism from section 5, but the actual $proceed() return value of the shipment stays untouched.
8. Deployment, sort order, and testability of plugins
Once several modules plugin InvoiceRepositoryInterface, CreditmemoManagementInterface, or ShipmentRepositoryInterface at the same time, the sortOrder in di.xml decides the execution order. For around plugins this order is especially critical, because an earlier plugin can completely prevent the $proceed call of a later plugin if it throws an exception. It is advisable to register validation plugins with a low sortOrder before pure extension plugins, so that invalid states are caught as early as possible before more expensive operations run. The disabled parameter allows a plugin to be deactivated in specific environments without removing the registration.
After every change to di.xml, the DI cache must be recompiled, in production mode via bin/magento setup:di:compile, in developer mode bin/magento cache:flush is usually enough. For tests, the rule is: around plugins can be tested in isolation in unit tests by mocking $proceed as a simple callable that returns a defined value. This lets the business logic in the plugin be tested independently of the actual Magento implementation. For the complete flow, including the interaction with the service contract itself, integration tests with a real repository instance are more useful.
9. Preference vs. plugin in direct comparison
The decision between preference and plugin for invoice logic and credit memo processing should not be a gut feeling, but a deliberate architectural decision. The following table compares the relevant criteria.
| Criterion | Preference (Rewrite) | Plugin (Interceptor) |
|---|---|---|
| Compatibility with other modules | Only one module can rewrite the same class | Any number of plugins per interface possible |
| Update safety | Breaks silently on a changed core class | Only the affected method is coupled |
| Scope of intervention | Entire class including unused methods | Targeted, one or a few methods |
| Testability | Entire original class needs to be retested | Plugin testable in isolation with mocked $proceed |
| Project convention | Contradicts the plugin-first approach | Matches service contracts and interceptor pattern |
In practice, hardly any case remains for invoice logic and credit memo processing that strictly requires a preference. Even deep changes to numbering, approval logic, or totals can be covered through around plugins, observers, and custom totals collectors. Preferences remain justified only where Magento offers no service contract and no plugin-capable public method access, which practically does not occur with the central sales interfaces.
10. Summary
The invoice logic and credit memo processing in Magento 2 can be extended almost entirely via plugins instead of preferences. An around plugin on InvoiceRepositoryInterface::save and InvoiceManagementInterface::setCapture covers custom numbering and validation. An around plugin on CreditmemoManagementInterface::refund enforces approval thresholds and business rules without replacing the core logic. Observers on sales_order_invoice_save_after and sales_order_creditmemo_save_after handle side effects such as ERP synchronization and audit log, without endangering the actual save operation.
For additional fee or discount lines, a dedicated totals collector based on AbstractTotal is the only robust path, since direct manipulation of grand total values in plugins collides with other modules. A plugin on ShipmentRepositoryInterface::save enables automatic invoice creation on shipment, without blocking the shipment process itself. A dedicated audit log entity via db_schema.xml, connected through a repository following the service contract pattern, makes every change to the invoice logic and credit memo processing traceable and testable.
Invoice logic and credit memo via plugin: the essentials at a glance
Invoice logic via plugin
Around plugin on InvoiceRepositoryInterface::save and InvoiceManagementInterface::setCapture instead of preference for numbering and validation.
Credit memo approval
Around plugin on CreditmemoManagementInterface::refund enforces thresholds and business rules before the refund.
Side effects via observer
sales_order_invoice_save_after and sales_order_creditmemo_save_after for ERP sync and audit log, decoupled from the save operation.
Totals & schema
Custom fee lines via AbstractTotal collector, audit log via db_schema.xml and service contracts.
11. FAQ: Invoice Logic and Credit Memo via Plugin
1Why plugin instead of preference for invoice logic?
2How does the around plugin on CreditmemoManagementInterface::refund work?
3Where are these plugins registered?
4Plugin or observer for invoices?
5Why not manipulate totals directly in the plugin?
6How do I store an audit log?
7Are multiple plugins on the same interface possible?
8How do I test an around plugin?
9Automatic invoice on shipment?
10What happens with two plugins on the same method?
Mironsoft
Magento 2 backend development, sales module, and service contracts
Invoice logic and credit memo workflows that fit your project?
We design and implement plugins, totals collectors, and audit log entities for your invoice and credit memo processes, cleanly via service contracts, without preferences and without rewrite conflicts on updates.
Plugin architecture
Around plugins for InvoiceRepositoryInterface and CreditmemoManagementInterface
Totals collector
Custom fee and discount lines registered cleanly via sales.xml
Audit log & ERP
Declarative schema for audit entities and observers for ERP synchronization