Skip to content

Country-aware address validation across checkout, customer account and admin #926

Description

@fballiano

Summary

This issue supersedes and closes #887. It scopes the first deliverable PR for address validation in Maho, narrowed to the structural (Tier 1) layer agreed in the #887 discussion. Tier 2 (paid provider adapters) and the email-typo / mailcheck idea are spun out into separate follow-ups so each lands with the right surface area and review.

Background

#887 proposed full address validation across checkout, customer account, and admin. The discussion settled on a two-tier design:

  • Tier 1: free, offline, country-aware structural validation (postcode regex, required fields, subdivision membership) driven by Google libaddressinput data.
  • Tier 2: pluggable paid providers (Google, Postcode.eu, Smarty, Deutsche Post, etc.).

The mahocommerce/directory-data package was restructured to mirror libaddressinput's JSON under formats/<CC>.json (CC-BY 4.0, weekly auto-update). Maho does not yet depend on it, so this issue also wires the composer dependency in.

Scope decisions confirmed during planning:

  1. Tier 1 only; provider interface deferred.
  2. Reorder checkout fields to Country → State/Province → Postcode → City → Street → Street 2 (per @tmewes's suggestion) so country selection drives downstream field hints.
  3. Read formats/*.json directly from the package at request time (in-process static cache). No DB import; the existing directory_country_format table keeps its current role.

Approach

Add a validator class in Mage_Directory that consumes formats/<CC>.json via Maho\DirectoryData\Paths, and wire it into Mage_Customer_Model_Address_Abstract::_basicCheck(). That method is the single chokepoint already shared by all five address flows (checkout billing, checkout shipping, customer address book, admin sales-order address, admin customer address), so no controller-level changes are needed.

Per the project guideline ("modify core files directly, avoid creating a new module unless asked"), this lands inside Mage_Directory (data) + Mage_Customer (wiring) + theme templates (reorder). No new module.

Client side: extend the existing RegionUpdater (public/js/varien/form.js and public/js/mage/adminhtml/form.js) so a country change also updates the postcode input's placeholder (from libaddressinput's zipex) and HTML5 pattern (from zip). The hint map is rendered server-side once per page; no extra XHR.

Implementation plan

New

  • app/code/core/Mage/Directory/Model/AddressFormat.php — validator with:
    • validatePostcode(string $countryCode, ?string $postcode): bool (anchors the zip regex)
    • validateSubdivision(string $countryCode, ?string $regionCode): bool (membership in sub_keys)
    • getRequiredFieldCodes(string $countryCode): array
    • getPostcodeExample(string $countryCode): ?string
    • getPostcodePattern(string $countryCode): ?string
    • hasSubdivisions(string $countryCode): bool
    • getClientHints(): array (country -> {example, pattern, hasSubdivisions, requiredCodes} map)
    • Loads each country's JSON lazily and caches in a static array.

Server-side validation wiring

  • app/code/core/Mage/Customer/Model/Address/Abstract.php::_basicCheck() — after existing checks, add:
    1. Postcode regex match when a zip exists for the country. Error: "Please enter a valid postcode for {country} (example: {example})."
    2. Subdivision-membership check when hasSubdivisions(country) and a region_id resolves to a region code. Error: "The selected state/province is not valid for the chosen country."
    3. Existing DB-driven isRegionRequired() policy toggle is preserved; libaddressinput data is additive.
  • The existing customer_address_validation_after event still fires after the new checks, so merchant observers / future Tier-2 providers can stack on top.

Composer

  • Add "mahocommerce/directory-data": "^1.0" to composer.json.

JS

  • public/js/varien/form.js — extend RegionUpdater to also drive postcode placeholder + pattern on country change.
  • public/js/mage/adminhtml/form.js — same treatment for the admin form.
  • public/js/varien/opcheckout.js — pass the postcode input id when constructing billingRegionUpdater / shippingRegionUpdater.

Server-rendered hint map

  • app/code/core/Mage/Directory/Block/Data.php — new getAddressFormatHintsJson() method, cached per-store via Mage::app()->getCache() (tag directory_address_format, TTL 24h).
  • Templates rendering the country select get a one-line <script>window.mahoAddressFormatHints = ...;</script> near the country dropdown.

Field reorder (Country → State → Postcode → City → Street → Street 2)

  • app/design/frontend/base/default/template/checkout/onepage/billing.phtml
  • app/design/frontend/base/default/template/checkout/onepage/shipping.phtml
  • app/design/frontend/base/default/template/checkout/onestep.phtml
  • app/design/frontend/base/default/template/customer/address/edit.phtml
  • app/design/adminhtml/default/default/template/customer/tab/addresses.phtml
  • The admin sales-order address form (programmatic via $fieldset->addField(...)) needs the same field order. PDF and address-display rendering use libaddressinput's per-country fmt string and are unaffected by input-form reordering.

Locale strings

  • app/locale/en_US/Mage_Customer.csv — three new entries:
    • "Please enter a valid postcode (example: %s)."
    • "Please enter a valid postcode for %s (example: %s)."
    • "The selected state/province is not valid for the chosen country."

Out of scope (separate follow-up issues)

  • Tier 2: pluggable provider interface + first reference adapter. Will be a separate issue. Per fballiano's note, shipping at least two adapters together (one global, one regional) before freezing the interface will avoid baking in a single provider's response shape.
  • Mailcheck / email-typo suggestions (raised by @mageaustralia in Feature: Address Validation #887). Separate concern; own issue.
  • Locale-aware postcode example rotation (currently we pick the first zipex value).

Acceptance criteria / verification

  • composer require mahocommerce/directory-data:^1.0 resolves cleanly.
  • vendor/bin/phpstan analyze passes at level 6.
  • vendor/bin/php-cs-fixer fix produces no diff in CI.
  • Pest backend tests cover postcode validation for IT / US / GB / DE, subdivision membership for IT, GB-without-subdivisions edge, and graceful no-op for unknown country codes.
  • Pest frontend integration test: POST /checkout/onepage/saveBilling with country_id=IT, postcode=0014 returns the new postcode error.
  • Manual smoke walk-throughs:
    • Guest checkout (Italy address): field order is Country → State → Postcode → City → Street; postcode input shows 00144 as placeholder; submitting 0014 is rejected with a clear message.
    • Customer address book: same behavior end-to-end.
    • Admin sales order: changing an order's address to a bogus US ZIP is rejected with the new message.
  • ./maho cache:flush regenerates the per-store hint-map cache.
  • composer test is green.

Closes #887.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions