For a range of workflow actions during Magento's order processing you might be interested in, hooking into a paid invoice, is one of the more interesting ones. Luckily for us Magento emits the sales_order_invoice_pay event when an invoice gets set to paid. What's more there is even some element of protection on this event that prevents this event being emitted more than once during the same process.

Using sales_order_invoice_pay can work for a lot of use cases. Unfortunately for me sales_order_invoice_pay is part of the checkout process so I wanted to keep this as lightweight as possible. For example sending an additional email should ideally be offloaded to an asynchronous process. I can recommend Erfan's excellent article on how to utilise Magento's queing functionality for this.

For us to be able to offload this to a queue we do need some reference to the invoice. This reference is the invoice's entity_id. Unfortunately Magento does not work with pre-generated ids but rather uses the autoincremented id generated by the database. This has the downside that an object needs to have been saved at least once for the id to become known. As you might have guessed by now this article exists since sales_order_invoice_pay may occur when the invoice has not yet been assigned an id.

Looking at the Magento entity saving process we can work out that the event sales_order_invoice_save_after happens after an ID has been generated.

    public function save(\Magento\Framework\Model\AbstractModel $object)
    {
        if ($object->isDeleted()) {
            return $this->delete($object);
        }

        $this->beginTransaction();

        try {
            if (!$this->isModified($object)) {
                $this->processNotModifiedSave($object);
                $this->commit();
                $object->setHasDataChanges(false);
                return $this;
            }
            $object->validateBeforeSave();
            $object->beforeSave();
            if ($object->isSaveAllowed()) {
                $this->_serializeFields($object);
                $this->_beforeSave($object);
                $this->_checkUnique($object);
                $this->objectRelationProcessor->validateDataIntegrity($this->getMainTable(), $object->getData());
                if ($this->isObjectNotNew($object)) {
                    $this->updateObject($object);
                } else {
                    $this->saveNewObject($object);
                }
                $this->unserializeFields($object);
                $this->processAfterSaves($object);
            }
            $this->addCommitCallback([$object, 'afterCommitCallback'])->commit();
            $object->setHasDataChanges(false);
        } catch (DuplicateException $e) {
            $this->rollBack();
            $object->setHasDataChanges(true);
            throw new AlreadyExistsException(new Phrase('Unique constraint violation found'), $e);
        } catch (\Exception $e) {
            $this->rollBack();
            $object->setHasDataChanges(true);
            throw $e;
        }
        return $this;
    }

Unfortunately for us this event will get triggered every time the invoice gets saved. During a normal checkout this can easily happen twice, once when the invoice gets first created and a second time once the invoice email has been sent. So we will need to check for this particular state change ourselves. We soon may come across the following method which sounds ideal:

    /**
     * Compare object data with original data
     *
     * @param string $field
     * @return bool
     */
    public function dataHasChangedFor($field)
    {
        $newData = $this->getData($field);
        $origData = $this->getOrigData($field);
        return $newData != $origData;
    }

What's more the original data gets populated for us once the object gets loaded:

    public function load($object, $entityId, $attributes = [])
    {
        \Magento\Framework\Profiler::start('EAV:load_entity');
        /**
         * Load object base row data
         */
        $object->beforeLoad($entityId);
        $select = $this->_getLoadRowSelect($object, $entityId);
        $row = $this->getConnection()->fetchRow($select);

        if (is_array($row) && !empty($row)) {
            $object->addData($row);
            $this->loadAttributesForObject($attributes, $object);

            $this->_loadModelAttributes($object);
            $this->_afterLoad($object);
            $object->afterLoad();
            $object->setOrigData();
            $object->setHasDataChanges(false);

In theory something like the following should do the trick:

        if ($invoice->getState() == Invoice::STATE_PAID
            && $invoice->dataHasChangedFor('state')
        ) {
           //one time process

Unfortunately a simplified code execution for an invoice and sending an invoice email:

$invoice->load(ID);
$invoice->pay();
$invoice->save();
$invoice->setEmailSent(true);
$invoice->save();

would already trigger our code twice. The reason for this is that after the save our original data is still the same as it was after the load.

I consider this a bug.

There is one further method which sounds promising:

    /**
     * Model StoredData getter
     *
     * @return array
     */
    public function getStoredData()
    {
        return $this->storedData;
    }

with storedData getting updated during the saving process in the afterSave() method:

    public function afterSave()
    {
        $this->cleanModelCache();
        $this->_eventManager->dispatch('model_save_after', ['object' => $this]);
        $this->_eventManager->dispatch('clean_cache_by_tags', ['object' => $this]);
        $this->_eventManager->dispatch($this->_eventPrefix . '_save_after', $this->_getEventData());
        $this->updateStoredData();
        return $this;
    }

Final Code Snippet

With this our final code in the event observer for sales_order_invoice_save_after is:

        $storedInvoiceData = $invoice->getStoredData();
        $previousInvoiceState = $storedInvoiceData['state'] ?? null;
        if ($invoice->getState() == Invoice::STATE_PAID
            && $previousInvoiceState != Invoice::STATE_PAID
        ) {
           //one time process

Final Notes

It is possible that our event gets processed but that the transaction gets rolled back. Usually using the event sales_order_invoice_save_commit_after would ensure that we are past the checkpoint of a rollback occuring. As the storedData update happens only in the afterSave() method after the sales_order_invoice_save_after event but before sales_order_invoice_save_commit_after we would need to consider how a rollback could impact us (this would be the same as for sales_order_invoice_pay as this event does not guarantee that the invoice will make it to persistent storage either).

An alternative event during the invoice creation process sales_order_invoice_register also occurs prior to the invoice getting saved leading to the same issue as described above.

Kristof Ringleff

Kristof Ringleff

Founder and Lead Developer at Fooman

Want to receive our monthly email with the best Magento developer tips, tricks and news? Join 7000+ other Magento developers