From 511a0ab33b5b12a4cd8a1d4dca6c5dce50cdb6c1 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Mon, 27 Jul 2026 20:52:15 -0300 Subject: Name the municipality behind a CEP that does not exist A CEP that fails at the checkout is almost never a random number. It is a shopper who knows perfectly well which town they live in and got a digit wrong, most often by reaching for the -000 that 957 municipalities never received. The API now names the municipality such a number would have belonged to, in the body of its own 404, so the message can say "that would be in Areal/RJ, which has CEPs from 25845-001 to 25849-899" instead of leaving the shopper at a dead end with nothing to act on. It stays an error. Nothing from it is written into a checkout field, on either checkout and in either lookup mode: a CEP that does not exist has no address, and filling one in from the municipality behind it means a parcel delivered to the wrong town and a chargeback behind that. The one module that reads the 404 body hands its callers a finished sentence and never the data itself, so there is no shape in which it could leak into a field later. The two CEPs quoted are the lowest and the highest that really exist there, never a list and never a suggestion, because the range is sparse enough that a number out of the middle of it would most likely not exist either. The widget correctly throws its 404 away without reading it, so the body is fetched once more, on the miss path only and once per CEP. In proxy mode PHP passes the municipality through with the 404 and caches it with the miss, which is what keeps the two modes indistinguishable from the browser. Anything missing or misshapen falls back to the message the plugin has always shown. --- includes/class-cepis-woo-assets.php | 21 ++++- includes/class-cepis-woo-lookup.php | 135 +++++++++++++++++++++++++++++---- includes/class-cepis-woo-messages.php | 10 +++ includes/class-cepis-woo-transport.php | 30 +++++++- 4 files changed, 178 insertions(+), 18 deletions(-) (limited to 'includes') diff --git a/includes/class-cepis-woo-assets.php b/includes/class-cepis-woo-assets.php index def6fe2..503441c 100644 --- a/includes/class-cepis-woo-assets.php +++ b/includes/class-cepis-woo-assets.php @@ -26,6 +26,15 @@ final class Cepis_Woo_Assets { */ private const WIDGET = 'cepis-autofill'; + /** + * Script handle for the shared "which municipality was that" helper. + * + * Its own file rather than a copy inside each integration: both checkouts + * need it, there is no build step to share code for them, and one module + * means one cache of what has already been asked. + */ + private const FAIXA = 'cepis-woocommerce-faixa'; + /** * Hook the enqueue. */ @@ -51,10 +60,18 @@ final class Cepis_Woo_Assets { true ); + wp_register_script( + self::FAIXA, + CEPIS_WOO_URL . 'assets/js/cepis-woocommerce-faixa.js', + array(), + CEPIS_WOO_VERSION, + true + ); + $handle = 'cepis-woocommerce-' . $checkout; $dependencies = 'blocks' === $checkout - ? array( self::WIDGET, 'wp-data', 'wc-blocks-data-store' ) - : array( self::WIDGET, 'jquery' ); + ? array( self::WIDGET, self::FAIXA, 'wp-data', 'wc-blocks-data-store' ) + : array( self::WIDGET, self::FAIXA, 'jquery' ); wp_enqueue_script( $handle, diff --git a/includes/class-cepis-woo-lookup.php b/includes/class-cepis-woo-lookup.php index 47e3f16..da2f25a 100644 --- a/includes/class-cepis-woo-lookup.php +++ b/includes/class-cepis-woo-lookup.php @@ -42,6 +42,32 @@ final class Cepis_Woo_Lookup { */ private const MISS_TTL = DAY_IN_SECONDS; + /** + * The fields of a 404's `faixa` object that are passed on. + * + * A whitelist rather than the whole object, so that a field the API grows + * later cannot reach the checkout unreviewed. None of these is ever written + * into a checkout field — see {@see Cepis_Woo_Lookup::faixa()}. + */ + private const FAIXA_FIELDS = array( + 'localidade', + 'uf', + 'estado', + 'regiao', + 'ibge', + 'cep_ini', + 'cep_fim', + 'ceps', + ); + + /** + * The transport the client speaks through, kept so that the body of a 404 + * can be read back after the client has turned it into an exception. + * + * @var Cepis_Woo_Transport|null + */ + private static ?Cepis_Woo_Transport $transport = null; + /** * Look a CEP up, from cache when possible. * @@ -57,21 +83,32 @@ final class Cepis_Woo_Lookup { $cached = get_transient( self::CACHE_PREFIX . $cep ); if ( is_array( $cached ) ) { - return isset( $cached['miss'] ) - ? self::not_found() - : array( - 'status' => 200, - 'body' => $cached, - 'retry_after' => null, - ); + if ( isset( $cached['miss'] ) ) { + $remembered = isset( $cached['faixa'] ) && is_array( $cached['faixa'] ) ? $cached['faixa'] : null; + + return self::not_found( $remembered ); + } + + return array( + 'status' => 200, + 'body' => $cached, + 'retry_after' => null, + ); } try { $address = self::client()->lookup( $cep ); } catch ( Cepis\CepNotFoundException ) { - set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL ); + $faixa = self::faixa( self::transport()->last_body() ); + $miss = array( 'miss' => true ); + + if ( null !== $faixa ) { + $miss['faixa'] = $faixa; + } + + set_transient( self::CACHE_PREFIX . $cep, $miss, self::MISS_TTL ); - return self::not_found(); + return self::not_found( $faixa ); } catch ( Cepis\InvalidCepException ) { return array( 'status' => 400, @@ -144,26 +181,94 @@ final class Cepis_Woo_Lookup { maxAttempts: 2, totalTimeout: 8.0, userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)', - transport: new Cepis_Woo_Transport(), + transport: self::transport(), ); } return $client; } + /** + * The transport, built once per request. + */ + private static function transport(): Cepis_Woo_Transport { + if ( null === self::$transport ) { + self::$transport = new Cepis_Woo_Transport(); + } + + return self::$transport; + } + + /** + * The municipality a missing CEP would have belonged to, out of the API's + * 404 body. + * + * **This is context, not an address.** A CEP that does not exist has no + * address, and nothing here may ever reach a checkout field: a shipping + * address filled from a CEP the Correios do not have is a parcel delivered + * somewhere else. It exists only so the shopper can be told what they most + * likely mistyped — the classic case being the `-000` that 957 + * municipalities never received. + * + * Returns `null` for anything unexpected, so a body that changed shape, or + * is not JSON at all, produces exactly the message this plugin has always + * shown. + * + * @param string $body The response body, as it arrived. + * + * @return array|null The named fields, or null. + */ + private static function faixa( string $body ): ?array { + if ( '' === $body ) { + return null; + } + + $data = json_decode( $body, true ); + + if ( ! is_array( $data ) || ! isset( $data['faixa'] ) || ! is_array( $data['faixa'] ) ) { + return null; + } + + $faixa = array(); + + foreach ( self::FAIXA_FIELDS as $name ) { + if ( ! isset( $data['faixa'][ $name ] ) || ! is_scalar( $data['faixa'][ $name ] ) ) { + continue; + } + + $value = $data['faixa'][ $name ]; + + $faixa[ $name ] = is_int( $value ) ? $value : sanitize_text_field( (string) $value ); + } + + return array() === $faixa ? null : $faixa; + } + /** * The API's own 404 body, reproduced so the browser cannot tell the two * modes apart. * + * `faixa` is **omitted** when there is none, never sent as null, which is + * what the API does and what the browser is written to expect. + * + * @param array|null $faixa The municipality the CEP + * would have belonged to, when the API named one. + * * @return array{status:int, body:array, retry_after:?int} */ - private static function not_found(): array { + private static function not_found( ?array $faixa = null ): array { + $body = array( + 'erro' => 'cep_nao_encontrado', + 'mensagem' => 'CEP não existe na base do DNE', + ); + + if ( null !== $faixa ) { + $body['faixa'] = $faixa; + } + return array( 'status' => 404, - 'body' => array( - 'erro' => 'cep_nao_encontrado', - 'mensagem' => 'CEP não existe na base do DNE', - ), + 'body' => $body, 'retry_after' => null, ); } diff --git a/includes/class-cepis-woo-messages.php b/includes/class-cepis-woo-messages.php index 201fe4e..93d3279 100644 --- a/includes/class-cepis-woo-messages.php +++ b/includes/class-cepis-woo-messages.php @@ -16,6 +16,12 @@ defined( 'ABSPATH' ) || exit; * * Keys are the widget's status kinds and error codes. Anything not listed * keeps the widget's own wording. + * + * `not_found_range` and `not_found_city` are the exception: they are not + * status kinds the widget knows, but templates the plugin's own JavaScript + * fills in when the API says which municipality a missing CEP would have + * belonged to. They live in the same table so that one filter, one text domain + * and one `.po` file cover everything the shopper is told. */ final class Cepis_Woo_Messages { @@ -33,6 +39,10 @@ final class Cepis_Woo_Messages { 'loading' => __( 'Looking the address up…', 'cepis-woocommerce' ), 'found' => __( 'Address found.', 'cepis-woocommerce' ), 'not_found' => __( 'CEP not found. Check the number or fill the address in by hand.', 'cepis-woocommerce' ), + /* translators: 1: municipality, e.g. Areal. 2: state code, e.g. RJ. 3: lowest CEP that exists there, e.g. 25845-001. 4: highest CEP that exists there, e.g. 25849-899. */ + 'not_found_range' => __( 'CEP not found. This number would be in %1$s/%2$s, which has CEPs from %3$s to %4$s. Check the number.', 'cepis-woocommerce' ), + /* translators: 1: municipality, e.g. Areal. 2: state code, e.g. RJ. */ + 'not_found_city' => __( 'CEP not found. This number would be in %1$s/%2$s. Check the number.', 'cepis-woocommerce' ), 'invalid' => __( 'Invalid CEP. Type all 8 digits.', 'cepis-woocommerce' ), 'rate_limited' => __( 'Too many lookups in a short time. Wait a few seconds and try again.', 'cepis-woocommerce' ), 'server' => __( 'The CEP service is unavailable. Fill the address in by hand.', 'cepis-woocommerce' ), diff --git a/includes/class-cepis-woo-transport.php b/includes/class-cepis-woo-transport.php index 888be54..b89a81c 100644 --- a/includes/class-cepis-woo-transport.php +++ b/includes/class-cepis-woo-transport.php @@ -23,6 +23,31 @@ defined( 'ABSPATH' ) || exit; */ final class Cepis_Woo_Transport implements Cepis\Transport { + /** + * Body of the response to the most recent completed request. + * + * The client turns a 404 into a {@see Cepis\CepNotFoundException} and drops + * the body with it, which is the right shape for a library: a CEP that does + * not exist is an error, not a payload. The API nonetheless puts something + * useful in that body — the municipality the number would have belonged to — + * and this is the only place in the plugin that still has it. + * + * Kept here rather than handed to the client so that nothing downstream can + * mistake it for an address. {@see Cepis_Woo_Lookup} reads it, keeps the few + * fields that name a municipality, and passes them on inside the 404. + * + * @var string + */ + private string $last_body = ''; + + /** + * The body of the last response, or an empty string when the last request + * never completed. + */ + public function last_body(): string { + return $this->last_body; + } + /** * Perform a single GET. * @@ -33,6 +58,8 @@ final class Cepis_Woo_Transport implements Cepis\Transport { * @throws Cepis\NetworkException The request never completed. */ public function get( string $url, array $headers, float $timeout ): Cepis\Response { + $this->last_body = ''; + $response = wp_remote_get( $url, array( @@ -48,11 +75,12 @@ final class Cepis_Woo_Transport implements Cepis\Transport { } $response_headers = wp_remote_retrieve_headers( $response ); + $this->last_body = (string) wp_remote_retrieve_body( $response ); return new Cepis\Response( (int) wp_remote_retrieve_response_code( $response ), is_object( $response_headers ) ? $response_headers->getAll() : (array) $response_headers, - (string) wp_remote_retrieve_body( $response ) + $this->last_body ); } } -- cgit v1.3