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.
InvoiceOrderInterface::execute(int $orderId, bool $capture = false, array $items = [], bool $notify = false, bool $appendComment = false, ?InvoiceCommentCreationInterface $comment = null, ?InvoiceCreationArgumentsInterface $arguments = null): intThe 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 insales_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
<?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.
<?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.
<?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.
<?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