Skip to main content
Version: next

Right of withdrawal

Since version 3.22

Front-Commerce ships a customer area where shoppers exercise their right of withdrawal, list the orders still eligible, submit a request on selected order lines, and follow its status. This guide explains how to enable the feature, how to implement its GraphQL contract on your own backend, and how to customize the user interface.

The right of withdrawal is mandated by the EU directive 2011/83: consumers may withdraw from a distance sale within a legal period, without justification. Because every European merchant is concerned, the GraphQL contract lives in @front-commerce/core and its user interface in @front-commerce/theme-chocolatine, while each backend extension provides its own implementation.

Enable the feature

The whole area is gated by the withdrawal extension feature flag. While it is inactive:

  • /user/withdrawals, /user/withdrawals/new and /user/withdrawals/:id answer 404;
  • no entry is rendered in the account navigation, on desktop and mobile alike;
  • the order detail page renders neither the pending request recap nor the action opening a new request.

@front-commerce/gezy enables the flag automatically. To enable it from another extension, register the feature in unstable_lifecycleHooks.onFeaturesInit:

my-extension/src/index.ts
return defineRemixExtension({
// ...
unstable_lifecycleHooks: {
onFeaturesInit: (hooks) => {
hooks.registerFeature("withdrawal", { flags: { enabled: true } });
},
},
});
caution

Raising the flag tells the storefront that your backend implements the Front-Commerce/Withdrawal contract. Enable it only once the resolvers described below are in place: the default core resolvers answer an empty list on the list fields, and throw on the single-item ones.

What the customer sees

RouteContent
/user/withdrawalsThe orders still eligible, and the requests already submitted with their status
/user/withdrawals/newThe withdrawal form for a given order: pick the lines and quantities, then state the reason
/user/withdrawals/:idA request with its items, its status history, and a cancellation action while it is still pending

The order detail page gets two additional slots of the orderDetails components map:

  • OrderWithdrawal recaps a pending request, right under the order summary;
  • OrderWithdrawalAction adds an entry in the order actions bar, opening a new request.

Both are filled by theme-chocolatine and hide themselves when the flag is down, so you have nothing to register to get them.

Implement the contract on your backend

The Front-Commerce/Withdrawal GraphQL module defines the schema and leaves every eligibility rule to the backend: the withdrawal period, the per-line quantities and the statuses are exposed as already-computed values. The storefront never recomputes them.

The schema

extend type Customer {
withdrawableOrders: [WithdrawableOrder!]!
withdrawableOrder(orderId: ID!): WithdrawableOrderDetail!
withdrawals: [Withdrawal!]!
orderWithdrawals(orderId: ID!): [Withdrawal!]!
withdrawal(id: ID!): WithdrawalDetail!
}

extend type Mutation {
createWithdrawal(
input: CreateWithdrawalInput!
): CreateWithdrawalMutationSuccess!
cancelWithdrawal(id: ID!): CancelWithdrawalMutationSuccess!
}

A request goes through four statuses, exposed as the WithdrawalStatus enum: REQUESTED, PROCESSED, REFUSED and CANCELED. Only a REQUESTED one may be cancelled by the customer, and only a REFUSED one carries a refusalReason.

Two fields deserve attention when mapping your own backend:

  • WithdrawableOrderDetail.lines must contain all the order lines, including the ones that cannot be withdrawn. The form displays them disabled, so the customer understands why an item is missing rather than wondering about it. Mark them with isEligible: false.
  • WithdrawableOrderLine.maxWithdrawableQuantity bounds the quantity input of the form. It should already account for the quantities withdrawn by previous requests, reported separately as withdrawnQuantity.

The module

Declare a GraphQL module depending on the core contract, and implement its resolvers against your own loaders:

my-extension/src/modules/withdrawal/index.ts
import { createGraphQLModule } from "@front-commerce/core/graphql";

export default createGraphQLModule({
namespace: "Acme/Withdrawal",
// The schema lives in Front-Commerce/Withdrawal: this module only implements
// it against the Acme backend.
dependencies: ["Front-Commerce/Withdrawal", "Acme/Customer"],
loadRuntime: () => import("./runtime"),
});
my-extension/src/modules/withdrawal/runtime.ts
import { createGraphQLRuntime } from "@front-commerce/core/graphql";
import WithdrawalLoader from "./loaders/WithdrawalLoader";

export default createGraphQLRuntime({
contextEnhancer: ({ config, services }) => ({
Withdrawal: new WithdrawalLoader(services, config.shop.currency),
}),
resolvers: {
Customer: {
withdrawableOrders: (_customer, _args, { loaders }) =>
loaders.Withdrawal.loadEligibleOrders(),
// ...
},
Mutation: {
createWithdrawal: async (_, { input }, { loaders }) => {
try {
const withdrawalId = await loaders.Withdrawal.createWithdrawal(input);

return { success: true, withdrawalId };
} catch (error) {
return {
success: false,
errorMessage:
error instanceof Error ? error.message : "Unknown error",
};
}
},
// ...
},
},
});

Both mutations implement MutationSuccessInterface: a rejected request is reported through success: false and an errorMessage rendered as-is to the customer, rather than through a GraphQL error. Surface the message of your backend when it carries one — it usually explains the refusal better than a generic sentence.

tip

Do not revalidate the quantities in your loader if your backend already rejects an ineligible line or an excessive quantity. Duplicating the rule lets the two implementations drift apart, and the customer would end up with a form that accepts what the backend refuses.

Customize the user interface

Every component of the area lives under theme/modules/Withdrawal/ and theme/pages/Account/Withdrawals/, and can be overridden as usual:

ComponentRole
theme/modules/Withdrawal/EligibleOrdersTableThe orders a request may still be opened on
theme/modules/Withdrawal/WithdrawalsTableThe requests already submitted
theme/modules/Withdrawal/WithdrawalFormLine selection, quantities, reason and confirmation modal
theme/modules/Withdrawal/WithdrawalStatusThe translated label of a status
theme/pages/Account/Withdrawals/WithdrawalA request with its items and history

The form holds only the line selection in React state: the quantity of a selected line lives in its NumberInput and travels with the form data. If you override WithdrawalFormTable, keep naming the quantity inputs items[<lineId>].quantity — the route action reads them back with that pattern, so that a line and its quantity cannot be mismatched.