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:
| Value | Behaviour |
|---|---|
disabled | The default. An account is required, as before. |
enabled | A visitor can order as a guest. |
enabled_unless_product_forbids | A 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.
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:
| Value | Form |
|---|---|
steps | The default. One page per step, with a progress trail. |
one_page | Every 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):
| Field | Rule |
|---|---|
| SIRET, address in France | 14 digits and a valid checksum. |
| Registration number, any other country | Up to 20 letters and digits. |
| VAT number, address in the European Union | A two letter country prefix that matches the country of the address, then 2 to 13 letters or digits. |
| French VAT number | FR, 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 Union | Free 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_required | Effect on the Flexy forms |
|---|---|
Missing, empty or any value but 0 | The SIRET and the VAT number are mandatory once a company name is typed. |
0 | They 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:
| Situation | Summary |
|---|---|
| One visible field in error | None. 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 token | Listed 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:
| Moment | What 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 rendered | The 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.