Skip to main content
Version: Next

Checkout

Added in Thelia 3.1: ordering without an account, a tunnel described by configuration rather than by theme code, and consent boxes the merchant manages.

Ordering without an account​

A visitor can place an order without opening an account. The guest is a customer row like any other, with no password, marked by customer.is_guest, so everything an order hangs off (the addresses, the invoice, the history) keeps working unchanged.

The feature ships switched off. guest_checkout_mode decides, and a shop that updates keeps the tunnel it had:

ValueBehaviour
disabledThe default. An account is required, as before.
enabledA visitor can order as a guest.
enabled_unless_product_forbidsA visitor can order as a guest unless the cart holds a product that forbids it.

A value the shop cannot read falls back to disabled.

In the third mode, a product is kept out of the guest tunnel by the product.guest_checkout_forbidden column, which the back office exposes on the product sheet. It is what a subscription, a downloadable licence or anything whose after-sales needs an account should carry.

In the back office, the guest_checkout_mode field is on the Store info screen, under Configuration > System.

Finding the order again​

A guest has no account to sign into, so the order confirmation carries a tracking link under /order/track/<token>. The token is a signed three-part string, orderId.expiresAt.signature, whose HMAC-SHA256 signature covers the order id, the expiry, the customer email and whether the row is still a guest. It is built by Thelia\Domain\Order\Service\GuestOrderAccessService::createToken() and keyed off the application secret, so it cannot be forged and stops working the moment the guest becomes a full customer.

The link lives for thirty days by default, which the guest_order_tracking_link_lifetime setting changes, in seconds.

Turning the purchase into an account​

A guest can open an account afterwards, keeping the orders already placed. The front API operation is:

POST /api/front/guest-customers/{id}/convert

It answers 202 Accepted. The conversion sets the password but leaves is_guest at 1 until an activation code is answered, through the same mechanism a normal registration uses (CustomerCodeManager::activateCustomerByCode()).

For developers, creating a guest dispatches TheliaEvents::CUSTOMER_GUEST_CREATE (action.create.customer.guest) with a CustomerGuestCreateEvent.

Configurable checkout steps​

The checkout used to be written into the theme: three pages plus a confirmation, each page checking the cart by hand and redirecting on its own. It is now described by configuration, in the checkout_step table, which carries the order, the activation and the wording of each step.

Four steps are seeded, with the codes cart, delivery, payment and confirmation. A merchant can turn off a step the shop does not use, and a download-only shop drops the delivery step, which is why delivery is the only one of the four that is not mandatory.

Turning a step off removes its screen while its check still runs. When the order is placed, the check of every registered step provider runs, in declared-position order, whatever the active flag says. A shop cannot sell its way around a guard by hiding a screen.

The tunnel keeps a shape: the cart opens it, the payment comes next to last, the confirmation closes it. The back office refuses any other arrangement, and a configuration broken behind its back falls back to the steps the code declares, with a warning in the log, rather than refusing to render.

A cart with nothing to ship no longer sees the delivery screen, and still gets the carrier the order cannot be placed without: CheckoutFacade::settleVirtualDeliveryIfNeeded() settles it once, and does nothing to any other cart.

Deploy the SQL before the code

checkout_step is read on every page of the tunnel. A missing table falls back to the providers' defaults with a log warning rather than breaking the checkout, but a shop should not run on that fallback.

The back-office screen is at /admin/configuration/checkout-step, behind the admin.configuration.checkout-step resource.

Display mode​

checkout_display_mode picks the form the theme renders:

ValueForm
stepsThe default. One page per step, with a progress trail.
one_pageEvery step stacked on a single screen, in an accordion, with no progress trail: the sections themselves say what is settled and what is left.

A value the shop cannot read falls back to steps. In the single-page form, a locked section is an unrendered section, and unlocking is display only: the refusal at placement stays on the server.

Shipping a step from a module​

A step row carries the merchant's choices. What a step does lives in code, on a service implementing Thelia\Domain\Checkout\Service\Step\CheckoutStepProviderInterface, autoconfigured under the thelia.checkout.step_provider tag:

namespace Thelia\Domain\Checkout\Service\Step;

interface CheckoutStepProviderInterface
{
public function code(): string;
public function defaultPosition(): int;
public function isMandatory(): bool;
public function isSkippedFor(Cart $cart): bool;
public function check(Cart $cart): void; // throws CheckoutException
public function componentName(): ?string;
}

Ship one such service and the row appears on the back-office screen at the next visit. A row whose provider is gone, because the module was uninstalled, is kept but left out of the tunnel.

componentName() is a hint and nothing more: the four core providers answer null, since naming a component is the theme's business. It earns its place when a module ships a step no theme has heard of.

CheckoutProgressionService is the single authority on where a cart stands, with activeSteps(), firstIncompleteStep() and isReachable(). It never reads the session, so the console and the front API consume it the same way a theme does.

Consents at payment​

The buyer accepts the terms and conditions before paying, and the merchant manages further consent boxes from the back office.

A consent carries a stable code, a translated title and description, an optional link to a content, a mandatory or optional flag and a position. It can be deactivated without being deleted.

The terms_and_conditions consent is created on install and on update, taking over the content the terms_conditions_content_id setting points at, so a shop that already had terms keeps them. A fresh install creates it mandatory; a shop updating from 3.0 gets it optional, so that a theme which does not render the consent box yet cannot block the checkout. Switch it to mandatory from the consent screen once the theme shows it.

A mandatory active consent left unanswered stops the order: ConsentGuard throws a MissingConsentException naming the consent by the wording the buyer was shown, rather than by its code.

In the back office, the consents are managed under Configuration > Order path > Checkout consents, at /admin/configuration/consent.

What the order keeps​

One order_consent row is written per active consent, inside the order transaction. It freezes the wording as it was displayed, the answer, the date and the buyer's IP address, so rewording a consent later never rewrites what was accepted. The row stores the consent code, not a foreign key: the consent may be deleted, the proof stays.

Orders created from the back office or from the console record nothing, since nobody was asked.

The IP address travels with the customer's personal data export, because it is half of what makes the acceptance evidence. Anonymization erases it and keeps the wording, the answer and the date. See Personal data.

Business addresses​

An address that names a company shows two more fields: the SIRET (the registration number) and the VAT number. They appear as soon as a company name is typed, on the address forms of the account, on the customer information form and on the guest checkout.

The core checks what is typed, whatever the door it comes through (front forms, API, back office):

FieldRule
SIRET, address in France14 digits and a valid checksum.
Registration number, any other countryUp to 20 letters and digits.
VAT number, address in the European UnionA two letter country prefix that matches the country of the address, then 2 to 13 letters or digits.
French VAT numberFR, a two character key, then the 9 digits of the SIREN. The key must be valid and the SIREN must match the first 9 digits of the SIRET when a valid SIRET is given. A number with a letter key is accepted as it is.
VAT number, address outside the UnionFree text, up to 20 characters.

A French VAT number that stops before the 9 digits of the SIREN (FR12, for instance) is refused. An address that already stores such a number shows the message on its next save, and the checkout refuses it when it is the invoice address (the invoice-address-incomplete refusal of the checkout API).

Neither field is mandatory in the core: an association, a VAT-exempt business or a company outside the Union has a name and no number to give. The Flexy theme makes both mandatory by default, and the address_legal_identifiers_required setting changes that. Added in Flexy 1.3.

address_legal_identifiers_requiredEffect on the Flexy forms
Missing, empty or any value but 0The SIRET and the VAT number are mandatory once a company name is typed.
0They stay on the form without being mandatory. A value that is typed is still checked by the rules above.

On the guest checkout the server never requires the two fields; the setting only decides whether the form marks them as required.

The confirmation page​

Added in Flexy 1.3.

The page shown after an order is placed receives the order as placed_order. It is read from the session of the buyer who placed it, never from the URL: a signed-in customer only gets an order of their own, and a visitor with no order in session gets none, so a template has to handle placed_order being empty.

{% if placed_order|default(null) %}
<p>{{ placed_order.ref }}</p>
{% endif %}

The title says "Your payment has been confirmed" when the order is paid, and "Your order has been placed" otherwise (a cheque or a bank transfer waiting for its money). The page declares the theme hook order-placed.additional-payment-info with the order, so that the payment module can tell the buyer what is left to do: see Theme hooks and Payment modules.

When the payment module of an order returns no response (a payment with no gateway), the theme redirects to the confirmation page, where the cart is emptied. The failure page cancels only the order this session has just placed. Another unpaid order of the same customer is left alone.

Error messages in the tunnel​

Added in Thelia 3.2, with Flexy 1.2.

Flexy tells the buyer what went wrong where the buyer can act on it, instead of answering with an error page.

Form errors​

Every form of the theme reports all of its errors after a submission. Each field in error shows its messages right under it, and the field carries aria-invalid and an aria-describedby pointing at the message, so assistive technology reads them too.

A summary at the top of the form lists the errors when one field is not enough to show them:

SituationSummary
One visible field in errorNone. The message under the field is enough.
Several fields in error"This form contains N errors.", then one entry per field with a link to it.
A field in error that the page does not show (a hidden field, or a field with no label)Listed, without a link. A single error of this kind still gets a summary.
An error of the form itself, such as a rejected CSRF tokenListed first, without being counted.

After a submission, the focus moves to the summary, or to the field to correct when there is no summary.

The summary is the FormErrors component (components/Molecules/FormErrors), which the form_start block of the Flexy form theme renders on every form.

The summary names each field by its label. A template that renames a field for one page does it with the override_labels() Twig function, called before form_start(), so the page and the summary use the same wording:

{% do override_labels(form, {
'email': 'Email address'|trans,
'password.first': 'Choose a password'|trans,
}) %}
{{ form_start(form) }}

A nested field is reached by a dotted path, and an unknown name is ignored. Passing {label: …} to form_row() changes the rendered label only, so the summary would still read the label the form type declared. Calling override_labels() after form_start() throws a LogicException.

Refusals in the tunnel​

Two refusals that used to end on a server error now come back to the buyer:

MomentWhat the buyer sees
Adding to the cart from the product page, when a module refuses the line (an InvalidCartException from a cart listener) or the stock ran out since the page was renderedThe message "This product could not be added to your cart." on the product page.
Placing the order, when a line sells out between the check and the stock decrement (StockShortageException)A redirect to the cart, with a message that names the product reference when it is known, and the cart showing the shortage line by line.

Both messages are the theme's own, translated in the language of the page. The message of a module's exception is written for a log and never reaches the buyer.