magendoo / module-withdrawal
EU withdrawal function (Article 11a, Directive 2011/83/EU as amended by Directive (EU) 2023/2673) for Magento 2: statutory two-step withdrawal flow, acknowledgment of receipt on a durable medium, and a full audit trail
Magendoo_Withdrawal
EU withdrawal function ("withdrawal button") for Magento 2 — implements Article 11a of the
Consumer Rights Directive 2011/83/EU, as inserted by Directive (EU) 2023/2673.
From 19 June 2026, every B2C shop that concludes distance contracts through an online interface
must offer a withdrawal function wherever a right of withdrawal exists: a continuously available,
prominently displayed entry point labeled "withdraw from contract here" (or an unambiguous
corresponding formulation), a statement form asking only for the consumer's name, contract details,
and an email address, a confirmation step labeled "confirm withdrawal", and an acknowledgment of
receipt on a durable medium containing the statement's content and the date and time of its
submission. This module adds exactly that to a Magento 2 storefront — the two-step statutory flow
for guests and logged-in customers, an undroppable acknowledgment email with automatic retries, a
full audit trail, an admin processing workflow, and privacy-aware data retention. It supplements —
it does not replace — the existing Article 11 withdrawal channels (the model withdrawal form of
Annex I(B) and free-form statements).
Legal notice
This module helps implement the Article 11a withdrawal function. It is not legal advice, and
installing it does not by itself make a shop compliant. Wording, placement, scope (which of your
products carry a withdrawal right), your withdrawal policy, and your terms and conditions remain
your responsibility — have your implementation reviewed by legal counsel before 19 June 2026.
See also: Merchant guide (non-technical, how to process requests) and
Legal background (the statutory requirements and how the module maps to
them).
Screenshots
| Storefront | Admin |
|---|---|
| [image: Start page] | [image: Request grid] |
| [image: Statement form] | [image: Request view] |
| [image: Review and confirm] | [image: Configuration] |
| [image: Footer link] | [image: Acknowledgment email] |
Features
- Statutory two-step flow for both guests and logged-in customers: statement form → review page
with a single confirmation button → success page with reference and submission timestamp. All
flow pages are markedcacheable="false", so the module is safe to run with full-page cache. - Continuous entry points: a visually distinct footer link on every page (configurable), a
My Account navigation link, plus a state-aware Withdrawal column in My Account → My Orders and
an action link on the order view page that deep-link logged-in customers straight into the
prefilled statement form. Guests reach the flow through an order number + email lookup — no
login required, email never appears in a URL, lookups are rate-limited per IP with a uniform
"not found" message to prevent order enumeration. - Statutory labels sourced from the Official Journal language versions of Directive (EU)
2023/2673 for English, German, and Romanian (see i18n coverage). Per-store-view
override fields exist for merchants whose counsel approves an "unambiguous corresponding
formulation". - Acknowledgment of receipt on a durable medium (email), sent immediately on submission,
containing the full statement content and the date and time of submission (store timezone and
UTC). Receipt-only wording — the email never implies the withdrawal was accepted. Delivery
failures are persisted and retried by cron every 5 minutes; when the retry limit is exhausted, an
alert email is sent to the merchant. - Persist-first design: the request and its items are saved in one database transaction before
any email is attempted. A mail failure can never lose a statement. Compliance records have no
foreign keys into the sales tables, so they survive order deletion. - Partial withdrawal: per-item quantity selection, with already-withdrawn quantities locked and
multiple partial requests per order supported. - Late submissions accepted and flagged by default: under Article 10, the withdrawal period is
extended by up to 12 months if the consumer was not properly informed, so hard-blocking after the
computed deadline risks rejecting statements that are still within the statutory period. A
blockmode is available for merchants who prefer it. - Admin workflow: grid under Sales → Withdrawal Requests, request view with the statement
snapshot exactly as submitted, and guarded status transitions — Approve, Decline (a written note
is required), Complete, Cancel, Resend Acknowledgment. A "Create Credit Memo" link opens
Magento's own credit memo form; the module does no refund math of its own. - Audit trail: every request stores a JSON snapshot of the statement as the consumer saw it
(including the labels displayed and the locale), the submission timestamp (UTC), IP and user
agent; every submission and status change is recorded as an order status-history comment. - Privacy / GDPR: an optional retention period after which name, email, IP, user agent, and the
consumer note of closed requests are anonymized (daily cron and CLI, with dry run). The
compliance record itself is kept. - Integrations: three CLI commands, an admin-token REST endpoint, and service contracts
(Api/) for programmatic access. - Optional reason field (never required — Article 11a allows no preconditions beyond
identification), optional consumer note, merchant notification email, and customer status-update
emails. - 185 unit tests; verified end-to-end (guest and logged-in flows, acknowledgment delivery, retry
cron, admin transitions, REST, CLI).
Requirements
- Magento Open Source 2.4.6 – 2.4.9
- PHP 8.2 – 8.4
Installation
Composer (once the package is published on Packagist)
composer require magendoo/module-withdrawal
bin/magento module:enable Magendoo_Withdrawal
bin/magento setup:upgrade
bin/magento cache:flush
Manual (app/code)
mkdir -p app/code/Magendoo/Withdrawal
# copy the module contents into app/code/Magendoo/Withdrawal/
bin/magento module:enable Magendoo_Withdrawal
bin/magento setup:upgrade
bin/magento cache:flush
Quick start
The module installs dormant (enabled = 0); nothing appears on the storefront until you switch
it on.
- Go to Stores → Configuration → Sales → Withdrawal Function.
- Set General → Enable Withdrawal Function to Yes
(config pathmagendoo_withdrawal/general/enabled). - Set Notifications → Merchant Notification Recipient so your team hears about new statements.
- Save and flush the cache:
bin/magento cache:flush. - Place a test order and walk through the flow end to end (see the
go-live checklist).
Configuration reference
All fields live under Stores → Configuration → Sales → Withdrawal Function
(section magendoo_withdrawal).
| Path | Default | Scope | Description |
|---|---|---|---|
magendoo_withdrawal/general/enabled |
0 |
website | Master switch for the withdrawal function (footer link, flow pages, account link). |
magendoo_withdrawal/general/period_days |
14 |
website | Statutory withdrawal period in days. |
magendoo_withdrawal/general/transit_allowance_days |
0 |
website | Days added after the last shipment date before the period starts, to approximate delivery (the period runs from the day the consumer receives the goods). |
magendoo_withdrawal/general/excluded_order_statuses |
canceled,closed,fraud |
website | Orders in these statuses are not offered the withdrawal function. |
magendoo_withdrawal/general/late_submission |
accept |
website | accept = accept statements after the computed period end and flag them as late (recommended, see Article 10); block = refuse them. |
magendoo_withdrawal/presentation/show_footer_link |
1 |
store view | Renders the withdrawal function label as a highlighted link in the storefront footer. |
magendoo_withdrawal/presentation/show_reason |
1 |
store view | Shows the optional reason select on the statement form. Never required. |
magendoo_withdrawal/presentation/function_label |
(empty) | store view | Override for the statutory function label. Empty = "Withdraw from contract here" (translated per locale). Deviations need legal review. |
magendoo_withdrawal/presentation/confirm_label |
(empty) | store view | Override for the statutory confirmation button label. Empty = "Confirm withdrawal" (translated per locale). Deviations need legal review. |
magendoo_withdrawal/notifications/sender_identity |
general |
website | Store email identity used as sender of all withdrawal emails. |
magendoo_withdrawal/notifications/merchant_recipient |
(empty) | website | Address notified when a new withdrawal statement is received. |
magendoo_withdrawal/notifications/merchant_enabled |
1 |
website | Whether the merchant notification email is sent. |
magendoo_withdrawal/notifications/customer_status_enabled |
1 |
website | Sends a status update email when a request is approved, declined, or completed. The acknowledgment of receipt is always sent — it is a statutory obligation and cannot be disabled. |
magendoo_withdrawal/notifications/ack_max_attempts |
5 |
website | Maximum delivery attempts for the acknowledgment of receipt before an alert is raised. |
magendoo_withdrawal/notifications/alert_address |
(empty) | website | Notified when an acknowledgment exhausts its retry limit. Falls back to the merchant notification recipient when empty. |
magendoo_withdrawal/privacy/retention_days |
0 |
website | Days after submission before personal data of closed requests is anonymized. 0 keeps personal data indefinitely. |
magendoo_withdrawal/security/rate_limit_attempts |
10 |
website | Maximum guest order lookup attempts per client IP within the window below. |
magendoo_withdrawal/security/rate_limit_window |
60 |
website | Guest lookup rate limit window in seconds. |
Storefront flow
Frontend route: withdrawal (all flow pages are cacheable="false").
-
Entry points — the footer link (label = the statutory function label, CSS class
magendoo-withdrawal-link, visually distinct from the surrounding footer links), a
"Withdraw from contract here" link in the My Account navigation, a Withdrawal column in
My Account → My Orders, and an action link on the customer order view page. All render
only while the module is enabled.The order-list column and order-view action are state-aware per order: an eligible (or
late-but-accepted) order shows the statutory action link that deep-links straight into the
prefilled statement form — the customer is already identified, so no lookup step is involved;
an order with active requests shows their references and "Withdrawal already submitted" (plus
the action link while withdrawable quantity remains); a blocked-expired order shows
"Withdrawal period expired"; excluded orders (e.g. canceled) show nothing. The whole order
list is resolved with one batched fetch, not per-row queries.[image: Order list with withdrawal column]
-
Start page (
withdrawal/) — logged-in customers see their orders with a per-order
eligibility state (withdrawal possible / period expired / already submitted / not eligible).
Guests get a lookup form for order number + order email; failures show one uniform message and
are rate-limited per IP. The verified email is kept in the session, never in a URL. -
Statement form (
withdrawal/index/form) — name and email are prefilled (editable), items
are listed with a "quantity to withdraw" input per item (set to 0 to keep an item — partial
withdrawal is built in), plus an optional reason select and an optional note. If the computed
period has expired and late submissions are accepted, a notice explains the statement can still
be submitted. -
Review page — the full statement is shown for review, followed by a single button carrying
the statutory confirmation label and nothing else. Pressing it submits the statement. -
Success page — shows the reference (e.g.
WD-000000042), the submission date and time, and
"We have sent an acknowledgment of receipt to …" — receipt wording, not approval.
On submission the module stores the request and its items in one transaction (including a JSON
snapshot of exactly what the consumer saw — labels, locale, items, period end), stamps
submitted_at in UTC, appends an order status-history comment, notifies the merchant, and sends
the acknowledgment of receipt. The acknowledgment email contains the reference, the submission date
and time (store timezone plus UTC), the full statement content, and the sentence that it confirms
receipt pursuant to Article 11a — explicitly not a decision on the withdrawal.
Admin workflow
Menu: Sales → Withdrawal Requests (ACL resources Magendoo_Withdrawal::requests for viewing,
::manage for state changes, ::config for configuration).
- Grid — reference, order, customer, email, status, partial/late flags, submitted and
acknowledged timestamps; full-text search, filters, and per-row View action. - Request view — the consumer statement snapshot (as submitted), the item list, a timeline
(submitted / acknowledgment status with attempt count and last error / status changes), links to
the order and to Create Credit Memo (Magento's own credit memo form — the module performs no
refund calculations), and the action buttons. - Status lifecycle —
received → approved | declined | cancelledand
approved → completed | cancelled. Declining requires a written note, which is stamped into
the admin note trail and the order history. Approving, declining, and completing send the
customer status-update email (if enabled). Every transition appends an order status-history
comment; the order status itself is never changed. - Resend Acknowledgment — re-sends the acknowledgment of receipt for the request at hand.
CLI reference
# List withdrawal requests, newest first (email addresses are masked in the output)
bin/magento magendoo:withdrawal:list [--status=received|approved|declined|completed|cancelled] [--limit=20]
# Resend the acknowledgment-of-receipt email for one request
bin/magento magendoo:withdrawal:resend-ack WD-000000042
# Anonymize personal data on closed requests past the retention period
bin/magento magendoo:withdrawal:anonymize [--dry-run]
All commands exit 0 on success and 1 on failure.
REST reference
One admin-token endpoint (ACL resource Magendoo_Withdrawal::requests); there are no anonymous
routes.
GET /rest/V1/magendoo-withdrawal/requests
Standard searchCriteria filtering, sorting, and paging:
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://example.com/rest/V1/magendoo-withdrawal/requests?\
searchCriteria[filter_groups][0][filters][0][field]=status&\
searchCriteria[filter_groups][0][filters][0][value]=received&\
searchCriteria[pageSize]=10"
Cron reference
Both jobs run in the default cron group.
| Job | Schedule | What it does |
|---|---|---|
magendoo_withdrawal_ack_retry |
*/5 * * * * |
Retries unsent acknowledgments of receipt: up to 50 unacknowledged requests per run, oldest first, each still under the configured attempt limit. On reaching the limit, a one-time alert email goes to the alert address (fallback: merchant recipient). |
magendoo_withdrawal_anonymize |
0 2 * * * |
Anonymizes name, email, IP, user agent, and the consumer note of completed, declined, and cancelled requests older than the retention period, and redacts the personal values inside the statement snapshot. No-op while retention_days is 0. |
i18n coverage
Shipped locales: en_US, de_DE, ro_RO — full coverage of the storefront flow, the admin
messages, and all four email templates.
The statutory label strings are taken from the official Official Journal language versions of
Directive (EU) 2023/2673 (CELEX:32023L2673) as published on EUR-Lex — they are sourced, not
translated (wording verified against EUR-Lex on 2026-07-21):
| Locale | Function label | Confirmation label | Source |
|---|---|---|---|
| en_US | Withdraw from contract here | Confirm withdrawal | OJ English version, Art. 11a(1) and (3) |
| de_DE | Vertrag widerrufen | Widerruf bestätigen | OJ German version; identical wording in the German transposition, § 356a BGB |
| ro_RO | Retrageți-vă din contract aici | Confirmați retragerea | OJ Romanian version |
Additional locales are welcome as contributions, but only when the label wording is sourced the
same way — from the Official Journal language version of the directive, not from a translation
service.
FAQ
Does this replace the model withdrawal form (Annex I(B)) or my withdrawal policy?
No. Article 11a supplements the existing Article 11 withdrawal channels. Consumers may still
withdraw using the model form or any unequivocal statement, and your withdrawal instructions and
T&C remain required. The module only adds the online withdrawal function.
Does approving a request refund the customer automatically?
No — deliberately. The request view links to Magento's Create Credit Memo form for the order;
refund amounts, shipping, and adjustments are calculated there by Magento core. The module contains
no refund math.
Can I turn off the acknowledgment email?
No. The acknowledgment of receipt is a statutory obligation under Article 11a(4). You can configure
the sender identity, the retry limit, and the alert address — but not disable the email. The
status-update and merchant-notification emails, by contrast, are optional.
Why are statements after the 14-day period still accepted by default?
Because the module cannot know whether the period actually expired. Under Article 10, the
withdrawal period is extended by up to 12 months if the consumer was not (properly) informed about
the right of withdrawal. The default therefore accepts late statements and flags them (is_late)
for your assessment. If you prefer, set general/late_submission to block.
Can I require the customer to give a reason?
No, and the module will not let you. Article 11a permits no preconditions beyond identification —
the reason select is optional at every layer, and can only be hidden entirely
(presentation/show_reason).
Why is there no CAPTCHA on the guest lookup?
A CAPTCHA is a precondition that could make withdrawing harder than buying. The module protects the
lookup with a per-IP rate limit and uniform error responses instead.
Can I change the button labels?
There are per-store-view override fields, but the statute requires the exact words or an
"unambiguous corresponding formulation" — have any deviation reviewed by legal counsel first. Leave
the fields empty to use the sourced statutory wording.
Does the module decide which of my products have a withdrawal right?
No. Article 16 exemptions (custom-made goods, sealed hygiene items, digital content after
performance began, and so on) are legal judgments about your catalog. Use the excluded order
statuses and your own policy for that; when in doubt, ask counsel.
What happens to the data if an order is deleted?
The withdrawal record survives — the tables carry no foreign keys into the sales tables. Personal
data can be scrubbed after your retention period while the compliance skeleton is kept.
License
Open Software License 3.0 (OSL-3.0).
Copyright © Magendoo. Version 1.0.0 (unreleased).
No changelog yet
The vendor hasn't published a changelog. Tagged releases appear in the Versions tab.
Requires 5
| Package | Constraint |
|---|---|
| magento/framework | >=103.0.6 |
| magento/module-email | >=101.1.6 |
| magento/module-sales | >=103.0.6 |
| magento/module-store | >=101.1.6 |
| php | ~8.2.0||~8.3.0||~8.4.0 |
Compatibility
Each Magento release line is installed on its supported PHP versions, then the module is built (DI compilation + static-content deploy) and its unit and integration suites are run. The matrix shows the lines and PHP versions the module is confirmed to install and run on. Code-quality results further down (phpstan, phpcs, …) are reported separately and never affect compatibility.
Code Quality
Advisory checks against the module's source. Static analysis runs once across the whole module; PHPStan re-runs per Magento + PHP version because resolvable symbols differ between releases. These NEVER affect the Compatibility badge. A phpcs finding can't make a module incompatible.
Static analysis
Coding standards (phpcs), mess detection (phpmd), copy-pasted code (cpd), PHP cross-version compatibility, composer.json validity. Each runs once for the whole module.
| Tool | Status | Findings | Summary |
|---|---|---|---|
| PHPCS | Warning | 36 | 36 warnings (ruleset: Magento2) |
| PHPMD | Warning | 105 | 105 rule violations (UnusedPrivateField:105) |
| Cpd | Warning | 1 | 1 duplicated chunk spanning 26 total lines (min-lines=5, min-tokens=70) |
| Composer validate | Info | 4 | valid; 4 advisory notes (composer validate --strict) |
PHPStan
Type-checks the module's PHP against a real Magento install at the configured gate level. Re-runs per Magento and PHP version because resolvable symbols differ between releases.
Tests
Unit and integration suites, run for each applicable Magento and PHP version. A test failure speaks to the module's behaviour, not its compatibility with a Magento line, so it is reported here separately and never reddens the compatibility matrix.
Unit tests
Integration tests
| Magento | PHP 8.2 | PHP 8.3 | PHP 8.4 | PHP 8.5 |
|---|---|---|---|---|
| 2.4.7 | N/A | N/A | ||
| 2.4.8 | N/A | N/A | ||
| 2.4.9 | N/A | N/A |
Security
Security checks run directly against the module: an audit of its declared dependencies for known vulnerabilities (composer audit) and a scan of its source for malware and web-shell signatures. Each runs once. A malware detection fails the version outright.
More from magendoo
View vendorEU Base Price (Grundpreis) display for Magento 2. Shows price per reference unit (kg, litre, m) on product pages, category listings, search results and cart. Compliant with EU Price Indication Directive 98/6/EC and German PAngV.
Shipping Restrictions module
Product labels for Magento 2 - first-class label entities with stable codes, manual assignment via product attribute, rule-computed assignments materialized by an indexer, PLP/PDP badges and GraphQL exposure
EU Omnibus Directive (2019/2161) price tracker for Magento 2. Records price changes with timestamps, computes the lowest prior price in the disclosure window, and displays it on PDP and PLP, with async queue processing for bulk operations.
Turn an existing module into recurring revenue.
If you already maintain a Magento 2 module on GitHub or GitLab, listing it on Packagento takes about five minutes. We mirror your tags, handle distribution signing, and route paid licenses through Stripe Connect, so you can keep shipping the way you already do.