Invoices, Shipments and Credit Memos

Invoices bill, shipments ship and credit memos refund; each can be partial, and each decides the order's next state.

SyntaxInvoiceOrderInterface::execute(int $orderId, bool $capture = false, array $items = [], bool $notify = false, bool $appendComment = false, ?InvoiceCommentCreationInterface $comment = null, ?InvoiceCreationArgumentsInterface $arguments = null): int

The order records what was agreed. Three further documents record what actually happened to the money and the goods. Each is a separate entity with its own table, its own items table and its own increment id.

What each one records

  • Invoice — the amount billed, in sales_invoice. Creating one with capture tells the payment gateway to take the money. Creating one without capture, called an offline invoice, records only that an invoice exists.
  • Shipment — what left the warehouse, in sales_shipment, plus tracking rows in sales_shipment_track.
  • Credit memo — the amount refunded, in sales_creditmemo. Online returns money through the gateway; offline records the refund without contacting it.

Each has a small numeric state of its own. An invoice is 1 (open), 2 (paid) or 3 (canceled). A credit memo is 1 (open), 2 (refunded) or 3 (canceled). These are not order states.

Partial documents

All three can cover part of the order. You pass an array of item-creation objects, each carrying an order item id and a quantity. Pass an empty array and Magento bills, ships or refunds everything still outstanding.

The effect on the order

The order's state is a consequence, not a setting. The first invoice or shipment on a new order moves it to processing. When everything is invoiced and shipped the order becomes complete. A full credit memo closes it.

What trips people up: canInvoice() and canShip() go false the moment nothing is outstanding, and canShip() is always false for a virtual order. Check them before you build item objects. These are plain PHP classes, so bin/magento cache:flush is enough in developer mode; production needs bin/magento setup:di:compile.

Example

Example · php
<?php
// app/code/Vendor/OrderOps/Model/PartialInvoicer.php
declare(strict_types=1);

namespace Vendor\OrderOps\Model;

use Magento\Framework\Exception\LocalizedException;
use Magento\Sales\Api\Data\InvoiceItemCreationInterfaceFactory;
use Magento\Sales\Api\InvoiceOrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;

class PartialInvoicer
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly InvoiceOrderInterface $invoiceOrder,
        private readonly InvoiceItemCreationInterfaceFactory $itemFactory
    ) {
    }

    /**
     * @param array<int, float> $quantities order item id => quantity to bill
     */
    public function invoice(int $orderId, array $quantities): int
    {
        /** @var \Magento\Sales\Model\Order $order */
        $order = $this->orderRepository->get($orderId);

        if (!$order->canInvoice()) {
            throw new LocalizedException(
                __('Order %1 has nothing left to invoice.', $order->getIncrementId())
            );
        }

        $items = [];

        foreach ($quantities as $orderItemId => $qty) {
            $item = $this->itemFactory->create();
            $item->setOrderItemId($orderItemId);
            $item->setQty($qty);
            $items[] = $item;
        }

        // Positional longhand, then the PHP 8 named-argument shorthand.
        // Both are the same call; the names come from the interface.
        return $this->invoiceOrder->execute($orderId, true, $items);
        // return $this->invoiceOrder->execute(orderId: $orderId, capture: true, items: $items);
    }
}

// Writes one sales_invoice row in state 2 (paid) and captures through the
// gateway. The order moves from new to processing, and the returned int is
// the new invoice's entity_id.

When to use it

  • Invoice only the lines a supplier has actually confirmed, leaving the rest open.
  • Create the shipment and attach the carrier tracking number from a warehouse callback.
  • Refund one line of a delivered order back to the customer's card rather than offline.
  • Explain why an order sitting at processing never reached complete.

More examples

A shipment with a tracking number

Reach for this when a warehouse or 3PL calls back with a tracking number. ShipOrderInterface::execute takes $orderId, $items, $notify, $appendComment, $comment, $tracks, $packages, $arguments in that order, which is why the empty array, the two booleans and the single null appear before the tracks. Note that $notify true is what actually sends the shipment email; creating the shipment on its own sends nothing.

Example · php
<?php
// app/code/Vendor/OrderOps/Model/Shipper.php
declare(strict_types=1);

namespace Vendor\OrderOps\Model;

use Magento\Sales\Api\Data\ShipmentTrackCreationInterfaceFactory;
use Magento\Sales\Api\ShipOrderInterface;

class Shipper
{
    public function __construct(
        private readonly ShipOrderInterface $shipOrder,
        private readonly ShipmentTrackCreationInterfaceFactory $trackFactory
    ) {
    }

    public function shipEverything(int $orderId, string $trackNumber): int
    {
        $track = $this->trackFactory->create();
        $track->setCarrierCode('dhl');
        $track->setTitle('DHL Express');
        $track->setTrackNumber($trackNumber);

        // Empty $items means "ship everything outstanding".
        // $notify true sends the shipment email.
        return $this->shipOrder->execute(
            $orderId,
            [],
            true,
            false,
            null,
            [$track]
        );
    }
}

// Writes sales_shipment plus one sales_shipment_track row and returns the
// shipment's entity_id. With all items invoiced, the order becomes complete.

An online refund against one invoice

There are two refund entry points and the difference matters. RefundOrderInterface::execute($orderId, ...) creates an offline credit memo against the order and has no $isOnline flag at all. RefundInvoiceInterface::execute($invoiceId, $items, $isOnline, ...) refunds against a specific invoice and can go back through the gateway. If a merchant reports that refunds are not reaching the customer's card, this is usually why: the code took the order route.

Example · php
<?php
// app/code/Vendor/OrderOps/Model/Refunder.php
declare(strict_types=1);

namespace Vendor\OrderOps\Model;

use Magento\Sales\Api\Data\CreditmemoItemCreationInterfaceFactory;
use Magento\Sales\Api\RefundInvoiceInterface;

class Refunder
{
    public function __construct(
        private readonly RefundInvoiceInterface $refundInvoice,
        private readonly CreditmemoItemCreationInterfaceFactory $itemFactory
    ) {
    }

    public function refundOneLine(int $invoiceId, int $orderItemId, float $qty): int
    {
        $item = $this->itemFactory->create();
        $item->setOrderItemId($orderItemId);
        $item->setQty($qty);

        // $isOnline true sends the refund to the payment gateway.
        return $this->refundInvoice->execute($invoiceId, [$item], true);
    }
}

// Writes one sales_creditmemo row in state 2 (refunded) and refunds through
// the gateway. The order stays processing unless the whole amount is refunded.

Guarding before you build anything

Use this in a bulk action or an integration that processes many orders, where one unshippable order should be skipped rather than throw. The three can* methods live on the concrete Magento\Sales\Model\Order rather than on OrderInterface, which is why the parameter is typed to the model. canShip() returns false for a virtual order such as a downloadable product, and that single case accounts for most "the shipment button is missing" reports.

Example · php
<?php
// app/code/Vendor/OrderOps/Model/DocumentGuard.php
declare(strict_types=1);

namespace Vendor\OrderOps\Model;

use Magento\Sales\Model\Order;

class DocumentGuard
{
    /**
     * @return string[]
     */
    public function allowedDocuments(Order $order): array
    {
        $allowed = [];

        if ($order->canInvoice()) {
            $allowed[] = 'invoice';
        }

        // False for a virtual order, a held order and a payment review order.
        if ($order->canShip()) {
            $allowed[] = 'shipment';
        }

        if ($order->canCreditmemo()) {
            $allowed[] = 'creditmemo';
        }

        return $allowed;
    }
}

// Output:
// ['invoice', 'shipment']

Discussion

  • Be the first to comment on this lesson.