magendoo / module-withdrawal

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

magento2-module Compatibility: 2.4.7-2.4.8 Code Quality: Fail Tests: N/A Security: Pass OSL-3.0

Are you the maintainer of magendoo?

Packagento pulls magendoo's Composer packages from the public registry so buyers can find them here.

Claim the namespace to take ownership, publish new releases directly, and start charging for premium versions.

Claim this namespace →

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 marked cacheable="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
    block mode 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.

  1. Go to Stores → Configuration → Sales → Withdrawal Function.
  2. Set General → Enable Withdrawal Function to Yes
    (config path magendoo_withdrawal/general/enabled).
  3. Set Notifications → Merchant Notification Recipient so your team hears about new statements.
  4. Save and flush the cache: bin/magento cache:flush.
  5. 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").

  1. 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]

  2. 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.

  3. 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.

  4. 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.

  5. 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 lifecyclereceived → approved | declined | cancelled and
    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.

Versions
Version Stability QA Status Compatibility Released
v1.1.0 stable Fail Magento 2.4.7-2.4.8 Details 2026-07-22 07:34:23
v1.0.0 stable Not tested Not yet tested Details 2026-07-21 20:58:41

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.

Compatibility matrix (Magento × PHP)
Magento PHP 8.2 PHP 8.3 PHP 8.4 PHP 8.5
2.4.7 Pass Pass
2.4.8 Pass Pass
2.4.9 Pass not tested

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.

Static analysis results
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.

PHPStan results by Magento and PHP version
Magento PHP 8.2 PHP 8.3 PHP 8.4 PHP 8.5
2.4.7 3 3
2.4.8 3 3
2.4.9 3 N/A

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

Unit tests results by Magento and PHP version
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

Integration tests

Integration tests results by Magento and PHP version
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.

Security results
Tool Status Findings Summary
Composer audit Pass 0
Malware scan Pass 0
License
OSL-3.0

More from magendoo

View vendor
Make it pay

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.