zerofront / module-zero-checkout
Zero for Magento Open Source: a one-page checkout without Knockout: server-rendered HTML, one native form, HTMX for the parts that refresh.
Package info
github.com/zerofrontdev/module-zero-checkout
Type:magento2-module
pkg:composer/zerofront/module-zero-checkout
Requires
- php: ~8.2.0||~8.3.0||~8.4.0
- magento/framework: 103.0.*
- magento/module-backend: *
- magento/module-bundle: *
- magento/module-catalog: *
- magento/module-checkout: *
- magento/module-checkout-agreements: *
- magento/module-config: *
- magento/module-cron: *
- magento/module-customer: *
- magento/module-directory: *
- magento/module-downloadable: *
- magento/module-offline-payments: *
- magento/module-payment: *
- magento/module-quote: *
- magento/module-sales: *
- magento/module-store: *
- magento/module-tax: *
- zerofront/module-zero-core: ^1.3.6
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
README
A one-page checkout for Magento Open Source that replaces the Knockout
checkout: server-rendered HTML, one native <form>, HTMX for the parts that
refresh (shipping rates, totals, regions, saved addresses, discount code).
Offline payment methods work with JavaScript turned off.
Install
Requires Magento Open Source 2.4.7–2.4.9 and PHP 8.2–8.4 with the GD extension built with WebP (php -r 'var_dump(function_exists("imagewebp"));' says true; Magento's own PHP requirements cover the rest). Without WebP in GD, Zero skips its WebP image copies and serves the originals.
composer require zerofront/module-zero-checkout bin/magento setup:upgrade bin/magento setup:di:compile # production mode bin/magento setup:static-content:deploy # production mode bin/magento cache:flush
Composer installs zerofront/module-zero-core with it.
Part of zerofront/zero, which installs the Zero theme and every Zero module at once.
Features
| Feature | Where |
|---|---|
| Contact, shipping and billing addresses, saved-address picker | checkout/index.phtml, shipping-address-fields.phtml, billing-address-fields.phtml |
| Default shipping address prefilled for signed-in customers; new addresses saved to the address book | Controller/Checkout/Index, Model/Checkout/ShippingAddressUpdater |
Browser autofill (autocomplete tokens per section) |
address templates |
| Live shipping rates and totals | Controller/Checkout/Shippingmethods |
| Discount code in the order summary | Controller/Checkout/Coupon, coupon.phtml |
| Order comment, saved on the order | Model/PlaceOrder/OrderComment, order-comment.phtml |
| Terms and Conditions, checked on the server | Model/PlaceOrder/AgreementsValidator, agreements.phtml |
| Sign in from the checkout page | Controller/Checkout/Login, login.phtml |
| Order summary with item options | ViewModel/Checkout/CheckoutPage |
| Payment: offline methods, purchase order; Braintree, Stripe, Authorize.Net, PayPal Standard from their own Zero modules | zero.checkout.payment.* containers |
Settings
Stores > Configuration > Zero > Checkout:
| Group | Setting | Path | Default |
|---|---|---|---|
| Zero Checkout | Enabled | checkout/zero_checkout/enabled |
Yes |
| Checkout Page | Discount Code Field | checkout/zero_checkout/coupon |
Yes |
| Checkout Page | Order Comment | checkout/zero_checkout/order_comment |
Yes |
| Checkout Page | Sign In at Checkout | checkout/zero_checkout/login |
Yes |
| Terms and Conditions | Enable Terms and Conditions | checkout/options/enable_agreements (Magento's) |
No |
| Payment Webhooks | Process Through the Queue | checkout/zero_checkout/webhook_queue |
No |
| Payment Webhooks | Recheck Pending Payments | checkout/zero_checkout/recheck_pending |
No |
The agreements are edited in Stores > Terms and Conditions. Payment methods are configured in Sales > Payment Methods.
Payment webhooks through the queue. Mollie, Klarna, Adyen, monobank and
LiqPay tell the store about a payment with a webhook or callback. The
controller checks that the call is the provider's and about one of its orders
(signature, the order's own payment id), then hands it to
Model\RedirectPayment\Webhooks: with the setting off, the handler runs while
the provider waits for the answer (503 when the provider can't be asked, so it
calls again); with it on, the call is published to the topic
zero.checkout.webhook and answered at once, and the consumer of the same name
runs the handler. Magento's queue framework carries it: RabbitMQ when
env.php has queue/amqp, the queue_message table otherwise. Cron starts
the consumer (cron_consumers_runner, on by default); it can also run on its
own: bin/magento queue:consumers:start zero.checkout.webhook. When the
provider can't be asked from the consumer, the call goes back in the queue
after a pause, five tries in all, then the order gets a comment and keeps
waiting. Each payment module registers its handler
(Model\RedirectPayment\WebhookHandlerInterface) by method code in its
di.xml; a module adding a redirect payment does the same. Run
bin/magento setup:upgrade after installing: it creates the queue.
Settling a redirect payment
A redirect payment (the customer pays on the provider's page) is settled by
whichever comes first: the customer's return, the provider's webhook, a cron
recheck. All of them go through Model\RedirectPayment:
One order, one update at a time (OrderLock). Every update of an order
runs under the lock zero_redirect_payment_<increment id> on the order read
again after the lock is taken (never a copy cached earlier in the request).
The lock is re-entrant within one process. A webhook and a return wait up to
10 seconds for it, the cron not at all. Lock busy: the webhook controller
answers 503 (OrderLockedException is a LocalizedException), so the
provider calls again; the queue consumer tries again later; the customer sees
"processing"; the cron skips the order.
The return (ReturnFlow). A return controller calls
ReturnFlow::run($order, $update, $onNotStarted). It locks the order,
runs the provider update (OrderUpdaterInterface::update(), which returns a
ReturnResult state) and turns failures into a state: lock busy is
PENDING, the provider not answering (LocalizedException) is
UNCONFIRMED, anything else is logged and UNCONFIRMED. $onNotStarted
runs only on NOT_STARTED, for the providers whose link can be made
unpayable (monobank, Klarna, Mollie, Adyen, Amazon Pay): the link is
withdrawn and the order cancelled. LiqPay passes it too, but only cancels
(its link is not made unpayable; P6-3, see below). ReturnResult then sends the customer to
the success page or back to the checkout with the cart restored.
A payment for a cancelled order is flagged, never booked
(LatePaymentInterface). Webhooks::settle checks the fresh order first:
when it is cancelled and the method's handler implements
LatePaymentInterface, the handler only asks the provider's status
(latePayment(), read-only: no capture, no authorization, no save) and, when
the provider holds a payment, OrderPayment::paidAfterCancel adds a history
comment, logs an error and raises a major admin notice ("Payment received for
cancelled order #…"), once per order (the marker zero_paid_after_cancel in
the payment's additional information). The order stays cancelled; it is never
reopened or invoiced: refund the payment in the provider's account. A
customer coming back to such an order sees "processing" and the cart is not
restored, so they aren't invited to pay twice. A handler without the
interface still gets the cancelled order in webhook(), as before.
Recheck of pending orders (Cron\RecheckPending, Updaters). With
Payment Webhooks > Recheck Pending Payments on (it is off by default), the
cron job zero_checkout_recheck_pending (every 15 minutes) asks the provider
about orders in pending_payment 10 minutes to 7 hours old, at most 200 per
run, oldest first, for the methods registered in
Zero\Checkout\Model\RedirectPayment\Updaters (each payment module adds
its OrderUpdaterInterface in its global etc/di.xml). The cron never
cancels by itself; an updater cancels only when the provider reports a
definitive failure. Only updaters that never cancel because nobody has paid
yet are registered: LiqPay and WayForPay are not, because they still cancel
on a declined or unstarted payment (see below), and neither is Portmone,
which cancels an order whose only documents are failed attempts while the
customer may still be retrying on Portmone's page. Afterpay and Zip, which
capture on the store's own call, are not registered either: their own crons
recheck, and their expiry asks the provider before cancelling
(OrderPayment::cancelUnlessPaid; a provider that can't be asked leaves the
order alone).
What Magento does on its own. An order still in pending_payment after
Stores > Configuration > Sales > Sales > Orders Cron Settings > Pending
Payment Order Lifetime (sales/orders/delete_pending_after, 480 minutes by
default) is cancelled by Magento's hourly cron sales_clean_orders, without
asking the provider. A payment that arrives after that is flagged as above.
LiqPay and WayForPay still cancel on a decline. A declined attempt
(LiqPay failure/error, WayForPay Declined), and a LiqPay return with
payment_not_found, cancel the order as before, until a sandbox test proves
the provider's link stays payable after a decline. Money arriving for such an
order later is flagged by the late-payment check.
The cart restored (zero_checkout_quote_restored). Whenever the cart of
a still-unpaid redirect order is made active again (ReturnResult on the
storefront, CartOrder::restoreQuote), the event
zero_checkout_quote_restored is dispatched with order and quote, so a
module that tied the cart to the order can let go of it (Zero_QuoteRequest
releases its quote claim). It may fire more than once for the same order
(a return checked again): a listener checks that the order is the one it
cares about.
Money claims at placement. What must be taken or reserved atomically
when an order is placed (a company's credit, a quote request's claim) sits
in around plugins on Magento\Sales\Api\OrderManagementInterface::place()
(Zero_QuoteRequest sortOrder 10 outside Zero_NetTerms 20; MSI's
inventory_reservations_placement has no sortOrder and wraps outside both), the
one point the theme, GraphQL, REST and the admin share; each plugin undoes
its own work when placing fails. PlaceOrderValidatorInterface checks are
form checks only: they run before
the totals are collected and have no release path.
Amounts and refund keys (Model\Money). MinorUnits turns an amount
into a currency's smallest unit and back (toMinor, fromMinor,
format), with ISO 4217 exponents and each provider's exceptions (STRIPE,
ADYEN, KLARNA, AMAZON, PAYPAL). RefundKey::for($payment, $baseAmount, $prefix, $maxLength) gives a refund's idempotency key or
reference from what is persisted (the payment's amount refunded so far and
this refund's amount): the same when Magento retries the same credit memo,
different for the next partial refund. Both are static, no DI.
Extending the checkout
Four extension points, all wired with layout XML or di.xml; nothing in
Zero_Checkout needs to change.
1. Add a field or a block to the page
Layout containers in zero_checkout_index.xml:
| Container | Position | Inside the checkout form? |
|---|---|---|
zero.checkout.login (block) |
above the form | no |
zero.checkout.shipping.after |
right after the shipping methods (a carrier's own fields) | yes |
zero.checkout.payment.express |
above the payment methods | yes |
zero.checkout.payment.forms |
below the payment methods (card fields) | yes |
zero.checkout.summary.totals.before |
order summary, above the totals | yes |
zero.checkout.place-order.before |
right above Place Order | yes |
Fields inside the form are posted with the order.
<!-- Acme/GiftWrap/view/frontend/layout/zero_checkout_index.xml --> <page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> <body> <referenceContainer name="zero.checkout.place-order.before"> <block class="Magento\Framework\View\Element\Template" name="acme.giftwrap" template="Acme_GiftWrap::checkout/gift-wrap.phtml"/> </referenceContainer> </body> </page>
<!-- Acme/GiftWrap/view/frontend/templates/checkout/gift-wrap.phtml --> <label class="flex items-center gap-2 mt-4 text-sm"> <input type="checkbox" name="acme_gift_wrap" value="1" class="checkbox checkbox-sm" /> <?= $escaper->escapeHtml(__('Gift-wrap this order')) ?> </label>
Style with the classes Zero's templates already use, or the theme's
Tailwind build won't include them (see the theme's docs/child-themes.md).
2. Check something before the order is placed
Implement Zero\Checkout\Api\PlaceOrderValidatorInterface. It runs after the
form's addresses, shipping method and payment are on the quote; throw
LocalizedException to stop the order, and the message is shown on the
checkout page.
A validator is a form check: it runs before the totals are collected and
nothing undoes it when placing fails later. Claiming money or a limited
resource belongs in an around plugin on
Magento\Sales\Api\OrderManagementInterface::place() (see "Money claims at
placement" above).
class GiftWrapValidator implements \Zero\Checkout\Api\PlaceOrderValidatorInterface { public function validate(\Magento\Quote\Model\Quote $quote, array $request): void { if (!empty($request['acme_gift_wrap']) && $quote->getIsVirtual()) { throw new \Magento\Framework\Exception\LocalizedException(__('Virtual items can\'t be gift-wrapped.')); } } }
3. Do something once the order exists
Implement Zero\Checkout\Api\AfterPlaceOrderInterface. A failure here is
logged and the shopper still reaches the success page.
class SaveGiftWrap implements \Zero\Checkout\Api\AfterPlaceOrderInterface { public function execute(\Magento\Sales\Api\Data\OrderInterface $order, array $request): void { if (!empty($request['acme_gift_wrap'])) { $order->addCommentToStatusHistory('Gift wrap requested.'); $this->orderRepository->save($order); } } }
Register both:
<!-- Acme/GiftWrap/etc/frontend/di.xml --> <type name="Zero\Checkout\Controller\Checkout\Placeorder"> <arguments> <argument name="validators" xsi:type="array"> <item name="acme_gift_wrap" xsi:type="object">Acme\GiftWrap\Model\GiftWrapValidator</item> </argument> <argument name="afterPlaceOrder" xsi:type="array"> <item name="acme_gift_wrap" xsi:type="object">Acme\GiftWrap\Model\SaveGiftWrap</item> </argument> </arguments> </type>
Items are keyed by name, so a module can also replace or remove a built-in
one (agreements, order_comment) by reusing its key.
4. Add a payment method
Zero Checkout lists only the payment methods it knows how to complete. A
payment module registers its method code (and an optional one-line
description) with Zero\Checkout\Model\SupportedPaymentMethods:
<!-- Acme/Pay/etc/frontend/di.xml --> <type name="Zero\Checkout\Model\SupportedPaymentMethods"> <arguments> <argument name="methods" xsi:type="array"> <item name="acme_pay" xsi:type="string" translate="true">Pay with your Acme wallet.</item> </argument> </arguments> </type>
Then, depending on the method:
- Offline / redirect after placing — nothing else; a method that needs an
off-site step puts the payment page's URL in the order payment's
additional information as
zero_redirect_url(Placeorder::REDIRECT_URL) while the order is placed, and the customer goes there instead of the success page (seeZero_Monobank,Zero_LiqPay). - Card fields or a client SDK — add the form to
zero.checkout.payment.formsand show it only while the method is selected (#payment-methods:has(input[value="acme_pay"]:checked) ~ #acme-pay-panelin CSS, as Zero_Braintree, Zero_Stripe and Zero_AuthorizeNet do). The script tokenizes on submit into a hidden field namedpayment_additional_data[<key>]; Placeorder puts every such field into the payment'sadditional_data, where your method'sassignData()(or an observer) reads it.
Zero_Braintree is the smallest complete example: a view model, a template, a script and one layout file.
What not to change
- The checkout
<form>posts tozero/checkout/placeorder; don't nest other<form>elements inside it (usetype="button"+hx-post, like the discount code). hx-sync="#shipping-methods:replace"on address fields keeps the newest rate request winning.form_keyis in the form; HTMX posts include it withhx-include="[name='form_key']".
What changed in each release: CHANGELOG.md.
License
OSL-3.0 or AFL-3.0, your choice: see NOTICE.md.
Zero is made by Fixstacks in partnership with Vilmate, a software development company. Documentation: https://zerofront.dev/docs. Demo store: https://demo.zerofront.dev.