Skip to main content

Payments and shipments

An order's payments and its parcels are each written by one class, called a ledger. A plugin never calls a ledger. It calls methods of the base class it extends, and the base class calls the ledger for it.

This page says what the ledgers guarantee and points to the method for each task. The methods themselves are documented on Payment plugins and Shipping plugins.

What it does​

LedgerWhat it writesThe door for a plugin
PaymentLedger (administrator/src/Service/Order/PaymentLedger.php)Payments, refunds, authorizations, reversals and chargebacks of an order (table #__alfa_order_payments)The payment base class, PaymentsPlugin
ShipmentLedger (administrator/src/Service/Order/ShipmentLedger.php)The parcels of an order and the order lines in each (table #__alfa_order_shipments)The shipping base class, ShipmentsPlugin

Both ledgers are core's own. Alfa's order screen and the REST API call them. No plugin that ships with Alfa does.

The rule

The comment at the top of PaymentsPlugin states it: the base class is the supported payment API. A gateway never calls PaymentLedger and never reads or writes #__alfa_order_payments itself. If the base class lacks something you need, that is a request to Alfa Commerce, not a reason to query the table.

This manual applies the same rule to shipping plugins and ShipmentLedger. The code states the rule for payments only. A comment in ShipmentsPlugin still mentions calling ShipmentLedger directly for batch work. Such a call records no plugin on the row, the standard shipping plugin does not make it, and this manual does not document it.

When it is used​

Every time a payment or a parcel is created, changed or deleted: by a plugin at placement, by a provider's or a carrier's callback, by staff on the order screen.

What a ledger guarantees​

GuaranteeIn plain words
One place writesEvery payment row and every parcel row goes through its ledger, so the same checks run and the same entry is written to the order's History, whoever asked.
The plugin of recordSince 2.2.0 a row created through a base class records the plugin that wrote it. Later changes, deletes and line assignments made through a base class work only on a row recorded as that plugin's. See The plugin of record.
A row is settled onceA change can be made conditional on the row's current status. Of two requests that confirm the same payment or the same delivery, exactly one writes.
Money stays inside the orderA payment cannot exceed the order's total. A refund cannot exceed what the order can still give back, or what is left on the payment it refunds.
Status automationWhen a change gives a payment or a parcel a new status, and the method's settings name an order status for it (status_on_<status>), Alfa moves the order. The move is recorded in the same save as the row and carried out after that save is final. A new row never moves the order.
The refund blockWhile a provider's report of a refund that the books do not hold is open on an order, no new refund may start on it. Staff resolve the report first.
A parcel's charge is consistentA taxed shipping charge must name its tax rate. Shipping is charged once per order. A cancelled or returned parcel does not count in the order's total.

How the order moves is described on Orders.

What you must provide​

Extend the base class of your plugin type and call its methods. Nothing else.

I want to, for a payment plugin​

I want toUseDocumented at
Know how much to chargeamountToCollect($order)The verbs
Record a new payment attemptpayment($order)->pending()->save()Recording and settling a payment
Change a payment rowpaymentUpdate($paymentId) and the builderRecording and settling a payment
Mark a payment paid when the provider confirms, oncepaymentUpdate($id)->expectStatus(...)->completed() written with settleAttempt()The verbs, Callbacks arrive twice
Close an attempt nobody will paysupersedeAttempt($paymentId, $reason)The verbs
Find my own rows on an ordergatewayPayments(), latestPendingPayment(), gatewayPaymentByReference()Reading
Know whether the order is settledorderBalance($orderId)Reading
Know what may still be refundedrefundableNow(), refundableRemainder(), refundFormMaximum()Before the refund
Refund when my method has no providerrefundPayment($paymentId, $amount, $options)A refund with no provider
Refund through a providerreserveRefund(), then settleRefund(), holdRefund() or releaseRefund()A refund through a provider
Tell Alfa about a refund made outside the shopunbookedProviderRefunds($paymentId)Telling Alfa about refunds it does not hold
Record a chargeback, or a refund that did not standrecordChargeback(), recordReversal()The verbs
Move the order when a payment changesNothing in code. The method's settings status_on_<status> do it.The order's status
Move the order at a moment the settings cannot expressorderTransition($orderId)->to($statusId)->save()The verbs

I want to, for a shipping plugin​

I want toUseDocumented at
Record a new parcelshipment($order)->pending()->withAllItems()->cost(...)->save()Starting a write, What you chain on the builder
Change a parcel: status, tracking number, carriershipmentUpdate($shipmentId) and the builderTracking and status
Change a parcel when the carrier confirms, oncesettleAttempt($write, $orderId, $expected, $reference)Tracking and status
Split an order over several parcelswithItems([...]), assignShipmentItems()Lines and several parcels
Keep my own facts on a parcelpluginData([...]) on the builderFacts of your own on a parcel
Read or delete a parcelgetShipment(), getShipmentsByOrder(), deleteShipment()Lines and several parcels
Know what a save checksWhat save() guarantees and what it refuses
Move the order when a parcel is deliveredNothing in code. The method's settings status_on_<status> do it.A parcel's status can move the order

What happens next​

The ledger checks the write, saves the row with its entry in the order's History, and records any move of the order as background work. After the save is final, the order moves, and the move's own follow-up work runs: stock, documents, the status e-mail, onOrderStatusChange.

What can go wrong​

What you seeThe usual cause
save() returns false on a row you did not createThe row is recorded as another plugin's, or as no plugin's
save() returns false on a new rowThe order's method is not a method of your plugin
A payment is marked paid twice, or the order's e-mail is sent twiceThe change was not written with settleAttempt()
The order did not move after save()The row was new (a new row never moves the order), or the method's settings name no status. Inside a lock or a transaction of your own, the order moves after it ends.
A refund is refused though money is leftThe refund block holds: an unresolved report of a provider refund is open on the order
A row you wrote with your own SQL is not counted, or breaks a totalThe ledger was bypassed. Do not write these tables.

Changed in 2.2.0​

  • Changed in 2.2.0. OrderPaymentHelper and OrderShipmentHelper no longer exist. PaymentLedger and ShipmentLedger replaced them, and plugins reach them only through the base classes.
  • Changed in 2.2.0. Each row records the plugin that wrote it, and a base class changes only its own plugin's rows.
  • Changed in 2.2.0. The order moves after the row's save is final, as recorded background work.
  • Changed in 2.2.0. A parcel has no tracking link column and its builder has no trackingUrl(). A taxed charge must name its tax rate.

Not documented yet​

  • The ledgers' own public methods. They are core's own and are not an API for extensions.
  • Recording a payment or a parcel from an extension that is not the order's payment or shipping plugin.
  • The locks OrderMoneyLock and ShipmentBookingLock are described on Orders.