summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 16:41:48 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 16:47:14 -0300
commit535dbda6b48171f8115b455e54790de94d97d42a (patch)
tree5c0410011e61a883bdf063ee612875a637fb1d22
downloadwoocommerce-0.1.0.tar.gz
Initial importv0.1.0
-rw-r--r--.gitignore5
-rw-r--r--.prettierrc.json3
-rw-r--r--LICENSE15
-rw-r--r--Makefile86
-rw-r--r--README.md149
-rw-r--r--assets/js/cepis-autofill.min.js1
-rw-r--r--assets/js/cepis-woocommerce-blocks.js243
-rw-r--r--assets/js/cepis-woocommerce-classic.js167
-rw-r--r--bun.lock17
-rw-r--r--cepis-woocommerce.php100
-rw-r--r--includes/class-cepis-woo-admin.php74
-rw-r--r--includes/class-cepis-woo-assets.php183
-rw-r--r--includes/class-cepis-woo-lookup.php170
-rw-r--r--includes/class-cepis-woo-messages.php60
-rw-r--r--includes/class-cepis-woo-options.php105
-rw-r--r--includes/class-cepis-woo-rest.php120
-rw-r--r--includes/class-cepis-woo-settings.php135
-rw-r--r--includes/class-cepis-woo-token.php76
-rw-r--r--includes/class-cepis-woo-transport.php58
-rw-r--r--languages/cepis-woocommerce-pt_BR.mobin0 -> 4292 bytes
-rw-r--r--languages/cepis-woocommerce-pt_BR.po116
-rw-r--r--lib/cepis/Address.php133
-rw-r--r--lib/cepis/CepNotFoundException.php15
-rw-r--r--lib/cepis/CepisException.php34
-rw-r--r--lib/cepis/Client.php549
-rw-r--r--lib/cepis/CurlTransport.php85
-rw-r--r--lib/cepis/Geo.php94
-rw-r--r--lib/cepis/Health.php76
-rw-r--r--lib/cepis/InvalidCepException.php19
-rw-r--r--lib/cepis/InvalidResponseException.php16
-rw-r--r--lib/cepis/NetworkException.php16
-rw-r--r--lib/cepis/RateLimitedException.php25
-rw-r--r--lib/cepis/Response.php50
-rw-r--r--lib/cepis/ServerErrorException.php22
-rw-r--r--lib/cepis/Transport.php45
-rw-r--r--package.json16
-rw-r--r--phpcs.xml.dist80
-rw-r--r--test/checkout.test.js598
-rw-r--r--test/compose.yml61
-rw-r--r--test/fake-api.js104
-rwxr-xr-xtest/setup.sh158
-rw-r--r--test/wp.js88
-rwxr-xr-xtools/install-phpcs.sh24
-rwxr-xr-xtools/update-vendor.sh30
-rw-r--r--uninstall.php41
45 files changed, 4262 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a25e854
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.phpcs/
+*.log
+.DS_Store
diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..de753c5
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "printWidth": 100
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..5e9104d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) 2026 Murilo Ijanc <[email protected]>
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..196cb5a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,86 @@
+PLUGIN := cepis-woocommerce
+VERSION := 0.1.0
+DIST := dist
+ZIP := $(DIST)/$(PLUGIN)-$(VERSION).zip
+
+PHP_LOCAL := $(shell command -v php 2>/dev/null)
+ifeq ($(PHP_LOCAL),)
+PHP := docker run --rm -v "$(CURDIR)":/app -w /app php:8.3-cli php
+else
+PHP := $(PHP_LOCAL)
+endif
+
+PHPCS := docker run --rm -v "$(CURDIR)":/app -v "$(CURDIR)/.phpcs/vendor":/phpcs -w /app \
+ php:8.3-cli /phpcs/bin/phpcs --standard=phpcs.xml.dist --no-colors
+
+SOURCES := cepis-woocommerce.php uninstall.php includes lib assets languages LICENSE README.md
+
+.DEFAULT_GOAL := help
+
+.PHONY: help install test lint format build check clean phpcs up down
+
+help:
+ @echo "targets:"
+ @echo " test stand the store up and drive the checkout in Chromium"
+ @echo " lint php -l, prettier --check, and phpcs when installed"
+ @echo " format apply prettier (writes)"
+ @echo " build produce $(ZIP)"
+ @echo " check lint, then test, then build"
+ @echo " clean remove build output and throw the test containers away"
+ @echo " phpcs fetch PHP_CodeSniffer and the WordPress standard"
+ @echo " up start the test store and leave it running"
+ @echo " down stop the test store"
+
+# node_modules is gitignored, so a fresh clone has no playwright-core and the
+# suite dies before the first assertion. Depending on it here is what makes
+# `make check` work on a machine that has only just cloned this.
+node_modules: package.json
+ bun install
+ @touch node_modules
+
+install: node_modules
+
+test: node_modules
+ sh test/setup.sh
+ bun test test/checkout.test.js
+
+lint: node_modules
+ @find . -name '*.php' -not -path './node_modules/*' -not -path './.phpcs/*' -not -path './dist/*' \
+ | sort | sed 's|^\./||' | xargs -n1 $(PHP) -l >/dev/null
+ @echo "php -l: no syntax errors"
+ @bunx prettier --check 'assets/js/cepis-woocommerce-*.js' 'test/*.js'
+ @if [ -x .phpcs/vendor/bin/phpcs ]; then \
+ $(PHPCS); \
+ else \
+ echo "phpcs: not installed, run 'make phpcs' for the WordPress standard"; \
+ fi
+
+format:
+ bunx prettier --write 'assets/js/cepis-woocommerce-*.js' 'test/*.js'
+
+phpcs:
+ sh tools/install-phpcs.sh
+
+build: clean-dist
+ @mkdir -p $(DIST)/$(PLUGIN)
+ @cp -R $(SOURCES) $(DIST)/$(PLUGIN)/
+ @cd $(DIST) && zip -qr $(PLUGIN)-$(VERSION).zip $(PLUGIN)
+ @rm -rf $(DIST)/$(PLUGIN)
+ @unzip -l $(ZIP) | tail -1
+ @echo "built $(ZIP)"
+
+check: lint test build
+
+up:
+ sh test/setup.sh
+
+down:
+ docker compose -f test/compose.yml down
+
+clean: clean-dist
+ docker compose -f test/compose.yml down -v --remove-orphans 2>/dev/null || true
+ rm -rf node_modules .phpcs
+
+.PHONY: clean-dist
+clean-dist:
+ @rm -rf $(DIST)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..be2a9f3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,149 @@
+# CEP Autofill for WooCommerce
+
+Fills the WooCommerce checkout address from the CEP the shopper types. Street,
+neighbourhood, city and state, from official Correios data (DNE) via the
+[public API at cep.is](https://cep.is).
+
+Works on **both** WooCommerce checkouts — the Cart/Checkout Blocks and the
+classic `[woocommerce_checkout]` shortcode — see
+[Supported checkouts](#supported-checkouts).
+
+No Composer, no npm, no build step: the PHP client and the JavaScript widget
+are bundled in the plugin.
+
+## Install
+
+Download the `.zip` from `dist/` (or run `make build`), then **Plugins → Add
+New → Upload Plugin** in WordPress. Activate it; WooCommerce must be active
+first.
+
+By default the shopper's browser talks to cep.is directly, so there is nothing
+to configure and no key to obtain.
+
+Requires WordPress 6.9 or newer, PHP 8.1 or newer, and WooCommerce.
+
+## Settings
+
+**WooCommerce → Settings → CEP autofill.**
+
+| setting | default | what it does |
+| -------------------------- | -------- | ----------------------------------------------------------------------------------- |
+| **Autofill** | on | look the address up while the shopper types |
+| **Lookups go through** | browser | the browser straight to cep.is, or this site's REST API |
+| **Fields already filled** | keep | keep what the shopper typed / only fill empty fields / always overwrite |
+| **House number** | on | move the cursor to the house-number field after a hit (classic checkout) |
+| **API address** | cep.is | point at your own instance of the API |
+
+### Lookups go through
+
+**The browser (default).** One cross-origin GET against a Cloudflare edge
+cache. No PHP runs, nothing is billed to the store, and the answer arrives in
+about twenty milliseconds.
+
+**This site.** The browser calls
+`/wp-json/cepis/v1/lookup/{token}/{cep}` instead, and PHP calls cep.is through
+`wp_remote_get()`. Choose this when a Content-Security-Policy on the checkout
+forbids external requests, or when you want the answers cached server-side.
+Answers are kept in transients: a hit for a week, since the database behind the
+API is regenerated fortnightly and a week is half a refresh cycle; a "does not
+exist" for a day, since a CEP absent today can appear in the next release.
+
+The route carries a short-lived token derived from the site's salts, and
+answers `403` without one. It is registered only while this mode is on.
+
+### Fields already filled in
+
+`keep` — the default — fills empty fields, replaces values this plugin wrote
+itself, and leaves anything the shopper typed alone, so correcting a street by
+hand survives the next CEP.
+
+The state is the exception and is always replaced: the CEP decides it, and
+WooCommerce pre-selects the store's own state on a fresh checkout.
+
+## Supported checkouts
+
+Both, and both verified in a real browser against a real store.
+
+| checkout | supported | how |
+| ---------------------------------------------- | --------- | --------------------------------------------------------------------------- |
+| **Cart/Checkout Blocks** (the default since WooCommerce 8.3) | yes | reads and writes the `wc/store/cart` data store; no DOM is touched |
+| **Classic** `[woocommerce_checkout]` | yes | the widget fills the fields directly and announces progress in an `aria-live` region |
+
+On the block checkout the fields are React-controlled, so the plugin drives the
+official `setShippingAddress` / `setBillingAddress` actions and reports failures
+through the checkout's own notice area. On the classic checkout the status
+message appears under the CEP field, styleable through
+`.cepis-autofill-status[data-cepis-state="…"]`.
+
+Both fill billing and shipping. The cart block's shipping calculator is not
+covered.
+
+Verified on WordPress 7.0.2, WooCommerce 10.9.4, PHP 8.3. `Requires at least`
+is declared as 6.9 because that is what WooCommerce 10.x itself requires; older
+WordPress was not tested.
+
+## Brazilian address fields
+
+WooCommerce has no house-number and no neighbourhood field. When
+[Brazilian Market on WooCommerce](https://wordpress.org/plugins/woocommerce-extra-checkout-fields-for-brazil/)
+adds `billing_number` and `billing_neighborhood` to the classic checkout, this
+plugin fills the neighbourhood and moves the cursor to the number. When they
+are absent it fills what exists and does nothing else — the neighbourhood is
+**not** written into `address_2`, which in WooCommerce means "apartment, suite,
+unit" and would produce a wrong shipping label.
+
+The block checkout has no such fields and no plugin currently adds them. A
+store that registers its own with
+`woocommerce_register_additional_checkout_field()` can map it:
+
+```php
+add_filter( 'cepis_woocommerce_blocks_fields', function ( $fields ) {
+ $fields['bairro'] = 'my-plugin/neighborhood';
+
+ return $fields;
+} );
+```
+
+The classic equivalent is `cepis_woocommerce_classic_fields`, which takes CSS
+selectors per address section, for themes and one-page-checkout plugins that
+rename the fields. `cepis_woocommerce_messages` overrides what the shopper is
+told.
+
+## Development
+
+```sh
+make # list the targets
+make test # stand a store up in Docker and drive the checkout in Chromium
+make lint # php -l, prettier --check, and phpcs when installed
+make format # apply prettier (writes)
+make build # produce dist/cepis-woocommerce-0.1.0.zip
+make check # lint, then test, then build
+make clean # remove build output and throw the test containers away
+```
+
+`make test` needs Docker and `bun`. It brings up WordPress, MySQL and WP-CLI
+from `test/compose.yml`, installs and configures WooCommerce, and drives both
+checkouts with Playwright's Chromium against a fake cep.is on the host, so no
+test touches the network. Install Chromium with `bunx playwright install
+chromium`, or point `CEPIS_CHROMIUM` at an existing binary.
+
+`make phpcs` fetches PHP_CodeSniffer and the WordPress standard into `.phpcs/`;
+`make lint` picks them up automatically once they are there.
+
+### Refreshing the bundled dependencies
+
+`lib/cepis/` is the [PHP client](https://cep.is) and
+`assets/js/cepis-autofill.min.js` is the
+[autofill widget](https://cep.is), both copied in rather than installed,
+because a WordPress site is deployed by copying files and never runs
+`composer install`.
+
+```sh
+tools/update-vendor.sh ../cepis-php ../cepis-autofill
+```
+
+That rebuilds the widget, copies both in, and leaves you a diff to review.
+
+## Licence
+
+ISC. See [LICENSE](LICENSE).
diff --git a/assets/js/cepis-autofill.min.js b/assets/js/cepis-autofill.min.js
new file mode 100644
index 0000000..1cb513e
--- /dev/null
+++ b/assets/js/cepis-autofill.min.js
@@ -0,0 +1 @@
+(()=>{var{defineProperty:D,getOwnPropertyNames:E,getOwnPropertyDescriptor:h}=Object,f=Object.prototype.hasOwnProperty;function m(V){return this[V]}var g=(V)=>{var K=(M??=new WeakMap).get(V),J;if(K)return K;if(K=D({},"__esModule",{value:!0}),V&&typeof V==="object"||typeof V==="function"){for(var B of E(V))if(!f.call(K,B))D(K,B,{get:m.bind(V,B),enumerable:!(J=h(V,B))||J.enumerable})}return M.set(V,K),K},M;var c=(V)=>V;function u(V,K){this[V]=c.bind(null,K)}var p=(V,K)=>{for(var J in K)D(V,J,{get:K[J],enumerable:!0,configurable:!0,set:u.bind(K,J)})};var s={};p(s,{default:()=>o});var y="0.1.0";var k=/\D+/g,d=/[\u0300-\u036f]/g,l=["cep","logradouro","complemento","unidade","bairro","localidade","uf","estado","regiao","ibge","gia","ddd","siafi"],Q={idle:"",loading:"Buscando endereço…",found:"Endereço encontrado.",not_found:"CEP não encontrado. Confira o número ou preencha o endereço manualmente.",invalid:"CEP inválido. Digite os 8 dígitos.",rate_limited:"Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo.",server:"O serviço de CEP está indisponível. Preencha o endereço manualmente.",network:"Não foi possível consultar o CEP. Verifique sua conexão e tente de novo.",timeout:"A consulta do CEP demorou demais. Tente de novo ou preencha manualmente.",invalid_response:"Resposta inesperada do serviço de CEP. Preencha o endereço manualmente."};class L extends Error{constructor(V,K,J={}){super(K,J.cause===void 0?void 0:{cause:J.cause});this.name="CepisAutofillError",this.code=V,this.status=J.status,this.retryAfterMs=J.retryAfterMs,this.cep=J.cep}}function U(V){let K=String(V==null?"":V).replace(k,"");if(K.length!==8)throw new L("invalid",Q.invalid,{cep:K});return K}var R=new Map;function b(){R.clear()}function a(V){if(!V)return;let K=V.trim();if(/^\d+$/.test(K))return Number(K)*1000;let J=Date.parse(K);return Number.isNaN(J)?void 0:Math.max(0,J-Date.now())}function n(V,K){try{let J=JSON.parse(V);if(J&&typeof J.mensagem==="string"&&J.mensagem!=="")return J.mensagem}catch{return K}return K}async function G(V,K={}){let J=U(V),B=(K.baseUrl??"https://cep.is").replace(/\/+$/,""),N=`${B}|${J}`,q=R.get(N);if(q){if(q.address)return q.address;throw new L(q.code,q.message,{cep:J})}let F=new AbortController,Y=()=>F.abort();if(K.signal){if(K.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});K.signal.addEventListener("abort",Y)}let j=!1,_=setTimeout(()=>{j=!0,F.abort()},K.timeoutMs??8000),H,O;try{H=await fetch(`${B}/${J}`,{method:"GET",credentials:"omit",headers:{Accept:"application/json"},signal:F.signal}),O=await H.text()}catch(W){if(j)throw new L("timeout",Q.timeout,{cep:J,cause:W});if(F.signal.aborted)throw new L("aborted","consulta cancelada",{cep:J});throw new L("network",Q.network,{cep:J,cause:W})}finally{if(clearTimeout(_),K.signal)K.signal.removeEventListener("abort",Y)}if(H.status===404)throw R.set(N,{code:"not_found",message:Q.not_found}),new L("not_found",Q.not_found,{cep:J});if(H.status===400)throw new L("invalid",n(O,Q.invalid),{cep:J});if(H.status===429)throw new L("rate_limited",Q.rate_limited,{cep:J,retryAfterMs:a(H.headers.get("Retry-After"))});if(H.status>=500)throw new L("server",Q.server,{cep:J,status:H.status});if(!H.ok)throw new L("invalid_response",Q.invalid_response,{cep:J,status:H.status});let z;try{z=JSON.parse(O)}catch(W){throw new L("invalid_response",Q.invalid_response,{cep:J,cause:W})}if(!z||typeof z!=="object"||Array.isArray(z))throw new L("invalid_response",Q.invalid_response,{cep:J});return R.set(N,{address:z}),z}function T(V){if(!V)return null;if(typeof V==="string")return document.querySelector(V);return V.nodeType===1?V:null}function I(V){return String(V??"").normalize("NFD").replace(d,"").trim().toLowerCase()}function C(V,K){let J=Object.getOwnPropertyDescriptor(Object.getPrototypeOf(V),"value");if(J&&J.set)J.set.call(V,K);else V.value=K;V.dispatchEvent(new Event("input",{bubbles:!0})),V.dispatchEvent(new Event("change",{bubbles:!0}))}function r(V,K){let J=K.filter(Boolean).map(I);for(let B of Array.from(V.options))if(J.includes(I(B.value))||J.includes(I(B.textContent)))return C(V,B.value),B.value;return null}function i(V,K,J){let B=String(V.value??"");if(K===!0)return!0;if(B.trim()==="")return!0;if(K===!1)return!1;return J.get(V)===B}var w=0;function A(V){if(!V||!V.cep)throw TypeError("attach() needs a `cep` selector or element");let K=(V.baseUrl??"https://cep.is").replace(/\/+$/,""),J=V.debounceMs??250,B=V.timeoutMs??8000,N=V.overwrite??"auto",q={...Q,...V.messages??{}},F=new WeakMap,Y={seq:0,timer:0,controller:null,region:null,describedBy:!1,detached:!1},j=()=>T(V.cep);function _(){if(V.status===!1)return null;if(Y.region&&Y.region.isConnected)return Y.region;if(V.status)Y.region=T(V.status);else{let $=j();if(!$)return null;let x=document.createElement("div");x.className="cepis-autofill-status",x.style.minHeight="1.25em",$.insertAdjacentElement("afterend",x),Y.region=x}let X=Y.region;if(!X)return null;if(X.setAttribute("role","status"),X.setAttribute("aria-live","polite"),X.setAttribute("aria-atomic","true"),!X.id)w+=1,X.id=`cepis-autofill-status-${w}`;let Z=j();if(Z&&!(Z.getAttribute("aria-describedby")??"").includes(X.id)){let $=Z.getAttribute("aria-describedby");Z.setAttribute("aria-describedby",$?`${$} ${X.id}`:X.id),Y.describedBy=!0}return X}function H(X){let Z=q[X]??"",$=_();if($)$.setAttribute("data-cepis-state",X==="found"||X==="loading"?X:X==="idle"?"idle":"error"),$.textContent=Z;let x=j();if(x){if(X==="loading")x.setAttribute("aria-busy","true");else x.removeAttribute("aria-busy");x.setAttribute("aria-invalid",X==="invalid"||X==="not_found"?"true":"false")}if(V.onStatus)V.onStatus(X,Z)}function O(X){for(let Z of l){if(Z==="cep"||!V[Z])continue;let $=T(V[Z]),x=X[Z];if(!$||typeof x!=="string"||x==="")continue;if(!i($,N,F))continue;if($.tagName==="SELECT"){if(r($,Z==="uf"?[x,X.estado]:Z==="estado"?[x,X.uf]:[x])!==null)F.set($,$.value);continue}C($,x),F.set($,x)}}async function z(X){Y.seq+=1;let Z=Y.seq;if(Y.controller)Y.controller.abort();let $=new AbortController;Y.controller=$,H("loading");try{let x=await G(X,{baseUrl:K,timeoutMs:B,signal:$.signal});if(Z!==Y.seq||Y.detached)return null;if(O(x),H("found"),V.onResult)V.onResult(x,{cep:X});let P=T(V.numero);if(V.focusNumero&&P&&document.activeElement===j())P.focus();return x}catch(x){if(Z!==Y.seq||Y.detached||x.code==="aborted")return null;let P=x instanceof L?x.code:"network";if(H(P),V.onError)V.onError(x,{cep:X});return null}finally{if(Y.controller===$)Y.controller=null}}function W(X){let Z=X.target;if(!(typeof V.cep==="string"?Z&&Z.nodeType===1&&Z.matches(V.cep):Z===V.cep))return;v(String(Z.value??""))}function v(X){clearTimeout(Y.timer);let Z=X.replace(k,"");if(Z.length!==8){if(Y.seq+=1,Y.controller)Y.controller.abort(),Y.controller=null;H(Z.length>8?"invalid":"idle");return}Y.timer=setTimeout(()=>z(Z),J)}return H("idle"),document.addEventListener("input",W,!0),document.addEventListener("change",W,!0),{detach(){if(Y.detached=!0,Y.seq+=1,clearTimeout(Y.timer),Y.controller)Y.controller.abort(),Y.controller=null;document.removeEventListener("input",W,!0),document.removeEventListener("change",W,!0);let X=j();if(X)X.removeAttribute("aria-busy"),X.removeAttribute("aria-invalid");if(Y.region&&Y.describedBy&&X){let Z=(X.getAttribute("aria-describedby")??"").split(/\s+/).filter(($)=>$&&$!==Y.region.id).join(" ");if(Z)X.setAttribute("aria-describedby",Z);else X.removeAttribute("aria-describedby")}if(Y.region&&!V.status)Y.region.remove();Y.region=null},lookup(X){clearTimeout(Y.timer);let Z=j(),$=X??(Z?Z.value:""),x;try{x=U($)}catch(P){if(H("invalid"),V.onError)V.onError(P,{cep:String($)});return Promise.resolve(null)}return z(x)},element:j}}var S={attach:A,lookup:G,normalizeCep:U,clearCache:b,CepisAutofillError:L,MESSAGES:Q,VERSION:y};if(typeof window<"u")window.CepisAutofill=S;var o=S;})();
diff --git a/assets/js/cepis-woocommerce-blocks.js b/assets/js/cepis-woocommerce-blocks.js
new file mode 100644
index 0000000..bcbab93
--- /dev/null
+++ b/assets/js/cepis-woocommerce-blocks.js
@@ -0,0 +1,243 @@
+/**
+ * WooCommerce Cart/Checkout Blocks.
+ *
+ * The block checkout is React and its address inputs are controlled: a value
+ * written into the DOM from outside is discarded on the next render, and the
+ * `wc/store/cart` data store — which is what the order is actually built from —
+ * never hears about it. So nothing here touches a field. The postcode is read
+ * from the data store, and the resolved address is written back through the
+ * store's own `setShippingAddress` / `setBillingAddress` actions, which is the
+ * documented extension point. React then re-renders the inputs itself.
+ *
+ * Only the request layer is shared with the classic integration:
+ * `CepisAutofill.lookup` is the widget's HTTP client without its DOM half —
+ * same cache, same error codes, same handling of 400, 404, 429 and 5xx.
+ *
+ * @module
+ */
+
+(function () {
+ var config = window.cepisWooCommerce;
+ var data = window.wp && window.wp.data;
+
+ if (!config || !config.fields || !window.CepisAutofill || !data || !data.select) {
+ return;
+ }
+
+ var CART = "wc/store/cart";
+ var NOTICES = "core/notices";
+
+ /**
+ * Where a notice has to be rendered for the Checkout block to show it.
+ */
+ var NOTICE_CONTEXT = "wc/checkout";
+
+ /**
+ * One id, so a new failure replaces the previous message instead of stacking.
+ */
+ var NOTICE_ID = "cepis-woocommerce-lookup";
+
+ var SECTIONS = {
+ shipping: { address: "shippingAddress", write: "setShippingAddress" },
+ billing: { address: "billingAddress", write: "setBillingAddress" },
+ };
+
+ var progress = {
+ shipping: { cep: "", timer: 0, written: {} },
+ billing: { cep: "", timer: 0, written: {} },
+ };
+
+ /**
+ * The one field the shopper is never asked about.
+ *
+ * A street name is worth protecting — the shopper may know better than the
+ * database. A state is not: the CEP decides it. WooCommerce also starts the
+ * checkout with the store's own state already selected, which the `"auto"`
+ * rule would read as the shopper's choice and never touch, quietly filing a
+ * Rio address under São Paulo.
+ */
+ var ALWAYS = "uf";
+
+ /**
+ * Whether a field may be written.
+ *
+ * Mirrors the widget's `overwrite` option: `"auto"` protects anything the
+ * shopper typed but replaces what a previous lookup wrote, so correcting the
+ * street by hand survives the next CEP.
+ *
+ * @param {string} field The API's name for the field.
+ * @param {string} current Value the store holds now.
+ * @param {string|undefined} written Value this plugin last wrote there.
+ * @returns {boolean}
+ */
+ function writable(field, current, written) {
+ if (field === ALWAYS || config.overwrite === true) {
+ return true;
+ }
+ if (current.trim() === "") {
+ return true;
+ }
+ if (config.overwrite === false) {
+ return false;
+ }
+ return written === current;
+ }
+
+ var pending = {};
+
+ /**
+ * Look a CEP up once, however many sections want it.
+ *
+ * With "use the same address for billing" on — the default — the shipping
+ * and the billing observer see the same postcode land in the same tick. The
+ * widget's own cache is only filled once a response is back, so without this
+ * the store would send two identical requests for every CEP a shopper types.
+ *
+ * @param {string} digits The eight digits.
+ * @returns {Promise<Record<string, any>>}
+ */
+ function resolve(digits) {
+ if (!pending[digits]) {
+ var forget = function () {
+ delete pending[digits];
+ };
+
+ pending[digits] = window.CepisAutofill.lookup(digits, {
+ baseUrl: config.baseUrl,
+ timeoutMs: config.timeoutMs,
+ });
+ pending[digits].then(forget, forget);
+ }
+
+ return pending[digits];
+ }
+
+ /**
+ * Show one message in the Checkout block's own notice area.
+ *
+ * @param {string} text Message, already translated.
+ */
+ function announce(text) {
+ data.dispatch(NOTICES).createNotice("error", text, {
+ id: NOTICE_ID,
+ context: NOTICE_CONTEXT,
+ isDismissible: true,
+ });
+ }
+
+ /** Take the message away. */
+ function silence() {
+ data.dispatch(NOTICES).removeNotice(NOTICE_ID, NOTICE_CONTEXT);
+ }
+
+ /**
+ * Write a resolved address into the store.
+ *
+ * @param {string} name Section name, `shipping` or `billing`.
+ * @param {Record<string, any>} address The API's answer.
+ */
+ function apply(name, address) {
+ var customer = data.select(CART).getCustomerData();
+ var current = (customer && customer[SECTIONS[name].address]) || {};
+ var written = progress[name].written;
+ var patch = {};
+ var changed = false;
+
+ Object.keys(config.fields).forEach(function (field) {
+ var key = config.fields[field];
+ var value = address[field];
+
+ if (!key || typeof value !== "string" || value === "") {
+ return;
+ }
+
+ var held = current[key] == null ? "" : String(current[key]);
+ if (!writable(field, held, written[key])) {
+ return;
+ }
+
+ patch[key] = value;
+ written[key] = value;
+ changed = true;
+ });
+
+ if (changed) {
+ data.dispatch(CART)[SECTIONS[name].write](patch);
+ }
+ }
+
+ /**
+ * Resolve one CEP and write what comes back.
+ *
+ * @param {string} name Section name, `shipping` or `billing`.
+ * @param {string} digits The eight digits.
+ */
+ function run(name, digits) {
+ silence();
+
+ resolve(digits).then(
+ function (address) {
+ if (progress[name].cep === digits) {
+ apply(name, address);
+ }
+ },
+ function (error) {
+ if (progress[name].cep !== digits || error.code === "aborted") {
+ return;
+ }
+ announce(config.messages[error.code] || error.message);
+ },
+ );
+ }
+
+ /**
+ * React to whatever the store now holds for one address.
+ *
+ * @param {string} name Section name, `shipping` or `billing`.
+ * @param {Record<string, any>|undefined} address Address from the store.
+ */
+ function observe(name, address) {
+ var seen = progress[name];
+
+ if (!address || String(address.country || "") !== "BR") {
+ return;
+ }
+
+ var digits = String(address.postcode || "").replace(/\D+/g, "");
+
+ if (digits.length !== 8) {
+ if (seen.cep !== "") {
+ seen.cep = "";
+ window.clearTimeout(seen.timer);
+ silence();
+ }
+ return;
+ }
+
+ if (digits === seen.cep) {
+ return;
+ }
+
+ seen.cep = digits;
+ window.clearTimeout(seen.timer);
+ seen.timer = window.setTimeout(function () {
+ run(name, digits);
+ }, config.debounceMs);
+ }
+
+ data.subscribe(function () {
+ var store = data.select(CART);
+ if (!store || typeof store.getCustomerData !== "function") {
+ return;
+ }
+
+ var customer = store.getCustomerData();
+ if (!customer) {
+ return;
+ }
+
+ Object.keys(SECTIONS).forEach(function (name) {
+ observe(name, customer[SECTIONS[name].address]);
+ });
+ }, CART);
+})();
diff --git a/assets/js/cepis-woocommerce-classic.js b/assets/js/cepis-woocommerce-classic.js
new file mode 100644
index 0000000..69d8068
--- /dev/null
+++ b/assets/js/cepis-woocommerce-classic.js
@@ -0,0 +1,167 @@
+/**
+ * Classic (shortcode) WooCommerce checkout.
+ *
+ * The form is rendered by PHP and nothing owns the fields afterwards, so the
+ * widget is left to do the whole job: debounce, request, write, announce. Most
+ * of this file is handing it the selectors WooCommerce uses and the settings
+ * the store owner chose.
+ *
+ * Selectors are passed as strings rather than elements on purpose. The widget
+ * resolves them on every use and delegates its listener from `document`, so a
+ * theme or one-page-checkout plugin that re-renders the billing form does not
+ * break the autofill.
+ *
+ * @module
+ */
+
+(function () {
+ var config = window.cepisWooCommerce;
+
+ if (!config || !window.CepisAutofill || !config.fields) {
+ return;
+ }
+
+ var DIACRITIC = /[\u0300-\u036f]/g;
+
+ /**
+ * Compare labels the way a person would: accents and case do not count.
+ *
+ * @param {unknown} value Anything printable.
+ * @returns {string}
+ */
+ function fold(value) {
+ return String(value == null ? "" : value)
+ .normalize("NFD")
+ .replace(DIACRITIC, "")
+ .trim()
+ .toLowerCase();
+ }
+
+ /**
+ * Set a value the way a person would, so validation in the page sees it.
+ *
+ * @param {HTMLInputElement|HTMLSelectElement} element Field to write.
+ * @param {string} value Value to put in it.
+ */
+ function write(element, value) {
+ var descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), "value");
+
+ if (descriptor && descriptor.set) {
+ descriptor.set.call(element, value);
+ } else {
+ element.value = value;
+ }
+
+ element.dispatchEvent(new Event("input", { bubbles: true }));
+ element.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+
+ /**
+ * Fill the state field.
+ *
+ * This one field is kept away from the widget, because it needs the opposite
+ * of the widget's `overwrite` rule. A street name is worth protecting — the
+ * shopper may know better than the database. A state is not: the CEP decides
+ * it. And WooCommerce pre-selects the store's own state on a fresh checkout,
+ * which `overwrite: "auto"` would read as a choice the shopper made and
+ * never touch, quietly filing a Rio address under São Paulo.
+ *
+ * Nothing is cleared when no option matches, and this runs only after a
+ * successful lookup, so a CEP that does not exist leaves the field alone.
+ *
+ * Focus is put back where it was. WooCommerce runs the state field through
+ * selectWoo, which takes focus for itself when the value changes, and that
+ * would otherwise strand the cursor on the state box instead of the house
+ * number the shopper still has to type.
+ *
+ * @param {string} selector CSS selector for the state field.
+ * @param {string} uf Two-letter state code from the API.
+ * @param {string} estado Full state name from the API.
+ */
+ function fillState(selector, uf, estado) {
+ var element = selector ? document.querySelector(selector) : null;
+
+ if (!element || !uf) {
+ return;
+ }
+
+ var value = uf;
+
+ if ("SELECT" === element.tagName) {
+ var wanted = [uf, estado].filter(Boolean).map(fold);
+ var options = Array.prototype.slice.call(element.options);
+ var match = null;
+
+ for (var i = 0; i < options.length; i += 1) {
+ if (
+ wanted.indexOf(fold(options[i].value)) !== -1 ||
+ wanted.indexOf(fold(options[i].textContent)) !== -1
+ ) {
+ match = options[i].value;
+ break;
+ }
+ }
+
+ if (null === match) {
+ return;
+ }
+
+ value = match;
+ }
+
+ if (element.value === value) {
+ return;
+ }
+
+ var active = document.activeElement;
+
+ write(element, value);
+
+ if (active && active !== document.activeElement && "function" === typeof active.focus) {
+ active.focus();
+ }
+ }
+
+ /**
+ * Attach one address section — billing or shipping.
+ *
+ * @param {Record<string, string>} map Selectors, keyed by the API's field names.
+ * @returns {object|null} The widget handle, or null when there is no CEP field.
+ */
+ function section(map) {
+ if (!map || !map.cep) {
+ return null;
+ }
+
+ var options = {
+ baseUrl: config.baseUrl,
+ overwrite: config.overwrite,
+ focusNumero: !!config.focusNumero,
+ debounceMs: config.debounceMs,
+ timeoutMs: config.timeoutMs,
+ messages: config.messages,
+ onResult: function (address) {
+ fillState(map.uf, address.uf, address.estado);
+ },
+ };
+
+ Object.keys(map).forEach(function (field) {
+ if ("uf" !== field) {
+ options[field] = map[field];
+ }
+ });
+
+ return window.CepisAutofill.attach(options);
+ }
+
+ var handles = [];
+
+ Object.keys(config.fields).forEach(function (name) {
+ var handle = section(config.fields[name]);
+ if (handle) {
+ handles.push(handle);
+ }
+ });
+
+ window.cepisWooCommerceHandles = handles;
+})();
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..ada24ec
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,17 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "devDependencies": {
+ "playwright-core": "^1.62.0",
+ "prettier": "^3.9.6",
+ },
+ },
+ },
+ "packages": {
+ "playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA=="],
+
+ "prettier": ["[email protected]", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
+ }
+}
diff --git a/cepis-woocommerce.php b/cepis-woocommerce.php
new file mode 100644
index 0000000..b9ae502
--- /dev/null
+++ b/cepis-woocommerce.php
@@ -0,0 +1,100 @@
+<?php
+/**
+ * Plugin Name: CEP Autofill for WooCommerce
+ * Plugin URI: https://cep.is
+ * Description: Fills the WooCommerce checkout address from the CEP the shopper types, with official Correios data (DNE) from cep.is.
+ * Version: 0.1.0
+ * Requires at least: 6.9
+ * Tested up to: 7.0
+ * Requires PHP: 8.1
+ * Requires Plugins: woocommerce
+ * Author: Murilo Ijanc
+ * Author URI: https://ijanc.org
+ * License: ISC
+ * License URI: https://opensource.org/licenses/ISC
+ * Text Domain: cepis-woocommerce
+ * Domain Path: /languages
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+define( 'CEPIS_WOO_VERSION', '0.1.0' );
+define( 'CEPIS_WOO_FILE', __FILE__ );
+define( 'CEPIS_WOO_DIR', plugin_dir_path( __FILE__ ) );
+define( 'CEPIS_WOO_URL', plugin_dir_url( __FILE__ ) );
+
+/**
+ * Autoload the bundled `Cepis\` PHP client.
+ *
+ * The library is vendored under `lib/cepis/` rather than installed with
+ * Composer, because a WordPress site is deployed by copying files and never
+ * runs `composer install`. Registration is appended, so a Composer autoloader
+ * already in play on the site keeps precedence and only one copy of the
+ * classes is ever declared.
+ *
+ * @param string $class_name Fully qualified class name being resolved.
+ */
+function cepis_woo_autoload( string $class_name ): void {
+ if ( ! str_starts_with( $class_name, 'Cepis\\' ) ) {
+ return;
+ }
+
+ $relative = substr( $class_name, 6 );
+ if ( str_contains( $relative, '\\' ) || ! preg_match( '/^[A-Za-z0-9_]+$/', $relative ) ) {
+ return;
+ }
+
+ $file = CEPIS_WOO_DIR . 'lib/cepis/' . $relative . '.php';
+ if ( is_readable( $file ) ) {
+ require_once $file;
+ }
+}
+
+spl_autoload_register( 'cepis_woo_autoload' );
+
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-options.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-messages.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-token.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-transport.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-lookup.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-rest.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-assets.php';
+require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-admin.php';
+
+/**
+ * Boot the plugin once WordPress has loaded its own translations.
+ *
+ * Every hook registered here fires at or after `init`, which is what keeps
+ * translation calls out of the `_load_textdomain_just_in_time` notice.
+ */
+function cepis_woo_init(): void {
+ load_plugin_textdomain( 'cepis-woocommerce', false, dirname( plugin_basename( CEPIS_WOO_FILE ) ) . '/languages' );
+
+ Cepis_Woo_Assets::init();
+ Cepis_Woo_Rest::init();
+
+ if ( is_admin() ) {
+ Cepis_Woo_Admin::init();
+ }
+}
+
+add_action( 'init', 'cepis_woo_init' );
+
+/**
+ * Declare compatibility with WooCommerce High-Performance Order Storage.
+ *
+ * The plugin never reads or writes an order, so the declaration is honest and
+ * keeps the store owner from seeing an "incompatible plugin" warning.
+ */
+function cepis_woo_declare_hpos(): void {
+ if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
+ \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', CEPIS_WOO_FILE, true );
+ \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', CEPIS_WOO_FILE, true );
+ }
+}
+
+add_action( 'before_woocommerce_init', 'cepis_woo_declare_hpos' );
diff --git a/includes/class-cepis-woo-admin.php b/includes/class-cepis-woo-admin.php
new file mode 100644
index 0000000..efb1959
--- /dev/null
+++ b/includes/class-cepis-woo-admin.php
@@ -0,0 +1,74 @@
+<?php
+/**
+ * Admin-side wiring.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Puts the settings screen where a store owner will look for it, and links to
+ * it from the plugins list.
+ */
+final class Cepis_Woo_Admin {
+
+ /**
+ * Hook everything the admin needs.
+ */
+ public static function init(): void {
+ add_filter( 'woocommerce_get_settings_pages', array( self::class, 'register_settings_page' ) );
+ add_filter( 'plugin_action_links_' . plugin_basename( CEPIS_WOO_FILE ), array( self::class, 'action_links' ) );
+ }
+
+ /**
+ * Add the settings tab to WooCommerce's own settings screen.
+ *
+ * The class is required here rather than at load time because it extends
+ * `WC_Settings_Page`, which does not exist until WooCommerce has booted its
+ * admin.
+ *
+ * @param array<int, mixed> $pages Settings pages WooCommerce has collected.
+ *
+ * @return array<int, mixed>
+ */
+ public static function register_settings_page( array $pages ): array {
+ if ( ! class_exists( 'WC_Settings_Page' ) ) {
+ return $pages;
+ }
+
+ require_once CEPIS_WOO_DIR . 'includes/class-cepis-woo-settings.php';
+
+ $pages[] = new Cepis_Woo_Settings();
+
+ return $pages;
+ }
+
+ /**
+ * Add a "Settings" link to the plugin's row.
+ *
+ * @param array<int|string, string> $links Existing action links.
+ *
+ * @return array<int|string, string>
+ */
+ public static function action_links( array $links ): array {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ return $links;
+ }
+
+ $url = admin_url( 'admin.php?page=wc-settings&tab=cepis' );
+
+ array_unshift(
+ $links,
+ sprintf(
+ '<a href="%s">%s</a>',
+ esc_url( $url ),
+ esc_html__( 'Settings', 'cepis-woocommerce' )
+ )
+ );
+
+ return $links;
+ }
+}
diff --git a/includes/class-cepis-woo-assets.php b/includes/class-cepis-woo-assets.php
new file mode 100644
index 0000000..def6fe2
--- /dev/null
+++ b/includes/class-cepis-woo-assets.php
@@ -0,0 +1,183 @@
+<?php
+/**
+ * What gets loaded on the checkout page, and with what configuration.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Detects which of WooCommerce's two checkouts is on screen and loads the
+ * matching integration.
+ *
+ * They are not variations of one another. The classic checkout is a server
+ * rendered form, so the widget can own the fields directly. The Cart/Checkout
+ * Blocks are React, the fields are controlled inputs, and writing to them from
+ * outside is at best ignored on the next render — so there the plugin never
+ * touches the DOM and drives the `wc/store/cart` data store instead.
+ */
+final class Cepis_Woo_Assets {
+
+ /**
+ * Script handle for the vendored widget.
+ */
+ private const WIDGET = 'cepis-autofill';
+
+ /**
+ * Hook the enqueue.
+ */
+ public static function init(): void {
+ add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue' ) );
+ }
+
+ /**
+ * Load the widget plus whichever integration this checkout needs.
+ */
+ public static function enqueue(): void {
+ $checkout = self::checkout_type();
+
+ if ( '' === $checkout || ! Cepis_Woo_Options::enabled() ) {
+ return;
+ }
+
+ wp_register_script(
+ self::WIDGET,
+ CEPIS_WOO_URL . 'assets/js/cepis-autofill.min.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' );
+
+ wp_enqueue_script(
+ $handle,
+ CEPIS_WOO_URL . 'assets/js/cepis-woocommerce-' . $checkout . '.js',
+ $dependencies,
+ CEPIS_WOO_VERSION,
+ true
+ );
+
+ wp_add_inline_script(
+ $handle,
+ 'window.cepisWooCommerce = ' . wp_json_encode( self::config( $checkout ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
+ 'before'
+ );
+ }
+
+ /**
+ * Which checkout is being rendered.
+ *
+ * @return string `blocks`, `classic`, or an empty string when this is not a
+ * checkout the plugin should touch.
+ */
+ private static function checkout_type(): string {
+ if ( ! function_exists( 'is_checkout' ) || ! is_checkout() ) {
+ return '';
+ }
+
+ if ( function_exists( 'is_order_received_page' ) && is_order_received_page() ) {
+ return '';
+ }
+
+ $post = get_post();
+
+ return ( $post instanceof WP_Post && has_block( 'woocommerce/checkout', $post ) ) ? 'blocks' : 'classic';
+ }
+
+ /**
+ * Everything the browser needs, in one object.
+ *
+ * @param string $checkout Either `blocks` or `classic`.
+ *
+ * @return array<string, mixed>
+ */
+ private static function config( string $checkout ): array {
+ return array(
+ 'baseUrl' => 'proxy' === Cepis_Woo_Options::mode()
+ ? Cepis_Woo_Rest::base_url()
+ : Cepis_Woo_Options::base_url(),
+ 'overwrite' => Cepis_Woo_Options::overwrite(),
+ 'focusNumero' => Cepis_Woo_Options::focus_number(),
+ 'debounceMs' => 250,
+ 'timeoutMs' => 8000,
+ 'messages' => Cepis_Woo_Messages::all(),
+ 'fields' => 'blocks' === $checkout ? self::blocks_fields() : self::classic_fields(),
+ );
+ }
+
+ /**
+ * CSS selectors for the classic checkout, per address section.
+ *
+ * `billing_number` and `billing_neighborhood` are not WooCommerce fields —
+ * they come from the "Brazilian Market on WooCommerce" plugin, which is the
+ * de-facto way a Brazilian store gets a house-number and a neighbourhood
+ * box. A selector that matches nothing is skipped, so listing them costs
+ * nothing on a store that does not have them.
+ *
+ * There is deliberately no fallback onto `address_2` for the neighbourhood:
+ * in WooCommerce that field means "apartment, suite, unit", and putting a
+ * neighbourhood there produces a wrong address on the shipping label.
+ *
+ * @return array<string, array<string, string>>
+ */
+ private static function classic_fields(): array {
+ $fields = array();
+
+ foreach ( array( 'billing', 'shipping' ) as $section ) {
+ $fields[ $section ] = array(
+ 'cep' => '#' . $section . '_postcode',
+ 'logradouro' => '#' . $section . '_address_1',
+ 'bairro' => '#' . $section . '_neighborhood',
+ 'localidade' => '#' . $section . '_city',
+ 'uf' => '#' . $section . '_state',
+ 'numero' => '#' . $section . '_number',
+ );
+ }
+
+ /**
+ * Filters the field selectors used on the classic checkout.
+ *
+ * Keys are the API's own field names; values are CSS selectors. A theme
+ * or one-page-checkout plugin that renames the fields hooks in here.
+ *
+ * @param array<string, array<string, string>> $fields Selectors per address section.
+ */
+ return (array) apply_filters( 'cepis_woocommerce_classic_fields', $fields );
+ }
+
+ /**
+ * Store API address keys for the Cart/Checkout Blocks.
+ *
+ * The block checkout has no house-number and no neighbourhood field, and no
+ * plugin currently adds one: "Brazilian Market on WooCommerce" only extends
+ * the classic form. A store that registers its own with
+ * `woocommerce_register_additional_checkout_field()` can map it here, using
+ * the namespaced key the Store API exposes it under.
+ *
+ * @return array<string, string>
+ */
+ private static function blocks_fields(): array {
+ $fields = array(
+ 'logradouro' => 'address_1',
+ 'localidade' => 'city',
+ 'uf' => 'state',
+ );
+
+ /**
+ * Filters the Store API address keys written on the block checkout.
+ *
+ * Keys are the API's own field names; values are keys of the address
+ * object in the `wc/store/cart` data store.
+ *
+ * @param array<string, string> $fields Address keys per API field.
+ */
+ return (array) apply_filters( 'cepis_woocommerce_blocks_fields', $fields );
+ }
+}
diff --git a/includes/class-cepis-woo-lookup.php b/includes/class-cepis-woo-lookup.php
new file mode 100644
index 0000000..47e3f16
--- /dev/null
+++ b/includes/class-cepis-woo-lookup.php
@@ -0,0 +1,170 @@
+<?php
+/**
+ * Server-side CEP lookups, cached.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Resolves a CEP with the bundled `Cepis\Client` and remembers the answer in a
+ * transient.
+ *
+ * Only the proxy mode goes through here. In the default direct mode the
+ * browser talks to cep.is itself and no PHP runs at all.
+ */
+final class Cepis_Woo_Lookup {
+
+ /**
+ * Prefix for the transient holding one CEP's answer.
+ */
+ private const CACHE_PREFIX = 'cepis_woo_';
+
+ /**
+ * How long a resolved address is kept.
+ *
+ * The DNE database behind the API is regenerated fortnightly, so a week is
+ * half a refresh cycle: an address can be at most one release behind, while
+ * a CEP a store sees repeatedly still costs one outbound request a week.
+ */
+ private const HIT_TTL = WEEK_IN_SECONDS;
+
+ /**
+ * How long a "this CEP does not exist" is kept.
+ *
+ * Much shorter than a hit, and for a different reason: a CEP absent today
+ * can appear in the next fortnightly base, and telling a shopper their
+ * brand-new street does not exist for two weeks is worse than making the
+ * request again tomorrow.
+ */
+ private const MISS_TTL = DAY_IN_SECONDS;
+
+ /**
+ * Look a CEP up, from cache when possible.
+ *
+ * Never throws: every outcome is described by the returned status and body,
+ * in exactly the shapes the public API uses, so the browser sees the same
+ * thing whichever mode the store is in.
+ *
+ * @param string $cep Eight digits, already validated by the caller.
+ *
+ * @return array{status:int, body:array<string, mixed>, retry_after:?int}
+ */
+ public static function resolve( string $cep ): array {
+ $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,
+ );
+ }
+
+ try {
+ $address = self::client()->lookup( $cep );
+ } catch ( Cepis\CepNotFoundException ) {
+ set_transient( self::CACHE_PREFIX . $cep, array( 'miss' => true ), self::MISS_TTL );
+
+ return self::not_found();
+ } catch ( Cepis\InvalidCepException ) {
+ return array(
+ 'status' => 400,
+ 'body' => array(
+ 'erro' => 'cep_invalido',
+ 'mensagem' => 'CEP deve ter 8 dígitos',
+ ),
+ 'retry_after' => null,
+ );
+ } catch ( Cepis\RateLimitedException $e ) {
+ return array(
+ 'status' => 429,
+ 'body' => array(
+ 'erro' => 'limite',
+ 'mensagem' => 'Muitas consultas em pouco tempo',
+ ),
+ 'retry_after' => null === $e->retryAfter ? null : (int) $e->retryAfter,
+ );
+ } catch ( Cepis\CepisException ) {
+ return array(
+ 'status' => 502,
+ 'body' => array(
+ 'erro' => 'interno',
+ 'mensagem' => 'falha interna',
+ ),
+ 'retry_after' => null,
+ );
+ }
+
+ $body = $address->raw;
+ set_transient( self::CACHE_PREFIX . $cep, $body, self::HIT_TTL );
+
+ return array(
+ 'status' => 200,
+ 'body' => $body,
+ 'retry_after' => null,
+ );
+ }
+
+ /**
+ * Drop every cached answer.
+ *
+ * Used by the settings screen when the API root changes, since answers from
+ * one instance say nothing about another.
+ */
+ public static function flush(): void {
+ global $wpdb;
+
+ $like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%';
+
+ $names = $wpdb->get_col(
+ $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like )
+ );
+
+ foreach ( (array) $names as $name ) {
+ delete_transient( substr( (string) $name, strlen( '_transient_' ) ) );
+ }
+ }
+
+ /**
+ * The client, built once per request.
+ */
+ private static function client(): Cepis\Client {
+ static $client = null;
+
+ if ( null === $client ) {
+ $client = new Cepis\Client(
+ baseUrl: Cepis_Woo_Options::base_url(),
+ timeout: 5.0,
+ maxAttempts: 2,
+ totalTimeout: 8.0,
+ userAgent: 'cepis-woocommerce/' . CEPIS_WOO_VERSION . ' (+https://cep.is)',
+ transport: new Cepis_Woo_Transport(),
+ );
+ }
+
+ return $client;
+ }
+
+ /**
+ * The API's own 404 body, reproduced so the browser cannot tell the two
+ * modes apart.
+ *
+ * @return array{status:int, body:array<string, mixed>, retry_after:?int}
+ */
+ private static function not_found(): array {
+ return array(
+ 'status' => 404,
+ 'body' => array(
+ 'erro' => 'cep_nao_encontrado',
+ 'mensagem' => 'CEP não existe na base do DNE',
+ ),
+ 'retry_after' => null,
+ );
+ }
+}
diff --git a/includes/class-cepis-woo-messages.php b/includes/class-cepis-woo-messages.php
new file mode 100644
index 0000000..201fe4e
--- /dev/null
+++ b/includes/class-cepis-woo-messages.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * The strings the shopper sees while a CEP is looked up.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The widget ships its own Portuguese messages; these replace them so that a
+ * store running in another locale gets the checkout in that locale, and so a
+ * store owner can reword them with the usual translation tooling.
+ *
+ * Keys are the widget's status kinds and error codes. Anything not listed
+ * keeps the widget's own wording.
+ */
+final class Cepis_Woo_Messages {
+
+ /**
+ * Build the message table.
+ *
+ * Must be called at or after `init`: it is the first thing in the plugin
+ * that translates.
+ *
+ * @return array<string, string> Message per status kind or error code.
+ */
+ public static function all(): array {
+ $messages = array(
+ 'idle' => '',
+ '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' ),
+ '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' ),
+ 'network' => __( 'Could not look the CEP up. Check your connection and try again.', 'cepis-woocommerce' ),
+ 'timeout' => __( 'The CEP lookup took too long. Try again or fill the address in by hand.', 'cepis-woocommerce' ),
+ 'invalid_response' => __( 'Unexpected answer from the CEP service. Fill the address in by hand.', 'cepis-woocommerce' ),
+ );
+
+ /**
+ * Filters the messages shown at the checkout while a CEP is resolved.
+ *
+ * @param array<string, string> $messages Message per status kind or error code.
+ */
+ $messages = (array) apply_filters( 'cepis_woocommerce_messages', $messages );
+
+ $clean = array();
+ foreach ( $messages as $key => $message ) {
+ if ( is_string( $key ) && is_scalar( $message ) ) {
+ $clean[ sanitize_key( $key ) ] = wp_strip_all_tags( (string) $message );
+ }
+ }
+
+ return $clean;
+ }
+}
diff --git a/includes/class-cepis-woo-options.php b/includes/class-cepis-woo-options.php
new file mode 100644
index 0000000..7a4a0b0
--- /dev/null
+++ b/includes/class-cepis-woo-options.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Stored settings and their defaults.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Reads the plugin's options, normalising whatever is in the database into the
+ * small set of values the rest of the plugin is allowed to see.
+ *
+ * Every getter returns a value from a fixed set: an option edited by hand in
+ * the database, or left over from an older release, can never reach the
+ * browser or the HTTP client as-is.
+ */
+final class Cepis_Woo_Options {
+
+ /**
+ * Option names, so that the settings screen and `uninstall.php` cannot
+ * drift apart.
+ */
+ public const NAMES = array(
+ 'cepis_woo_enabled',
+ 'cepis_woo_mode',
+ 'cepis_woo_overwrite',
+ 'cepis_woo_focus_number',
+ 'cepis_woo_base_url',
+ );
+
+ /**
+ * The public API, and the default for the `base_url` setting.
+ */
+ public const DEFAULT_BASE_URL = 'https://cep.is';
+
+ /**
+ * Whether the autofill should run at all.
+ */
+ public static function enabled(): bool {
+ return 'yes' === get_option( 'cepis_woo_enabled', 'yes' );
+ }
+
+ /**
+ * Where the browser sends its lookups.
+ *
+ * `direct` has the browser call cep.is itself: one cross-origin GET against
+ * an edge cache, with no PHP in the path. `proxy` routes the lookup through
+ * this site's REST API instead, for stores whose Content-Security-Policy
+ * forbids external XHR or whose owner wants the server-side cache.
+ *
+ * @return string Either `direct` or `proxy`.
+ */
+ public static function mode(): string {
+ return 'proxy' === get_option( 'cepis_woo_mode', 'direct' ) ? 'proxy' : 'direct';
+ }
+
+ /**
+ * What to do with a checkout field that already holds a value.
+ *
+ * @return bool|string `"auto"`, `true` or `false`, in the shape the widget
+ * expects for its own `overwrite` option.
+ */
+ public static function overwrite(): bool|string {
+ $value = (string) get_option( 'cepis_woo_overwrite', 'auto' );
+
+ if ( 'always' === $value ) {
+ return true;
+ }
+ if ( 'never' === $value ) {
+ return false;
+ }
+
+ return 'auto';
+ }
+
+ /**
+ * Whether to move the cursor to the house-number field after a hit.
+ *
+ * Only ever does anything on the classic checkout, and only when a
+ * house-number field exists.
+ */
+ public static function focus_number(): bool {
+ return 'yes' === get_option( 'cepis_woo_focus_number', 'yes' );
+ }
+
+ /**
+ * Root of the API to query, without a trailing slash.
+ *
+ * Overridable so a store can point at its own instance, and so the test
+ * suite can point at a fake one.
+ */
+ public static function base_url(): string {
+ $value = (string) get_option( 'cepis_woo_base_url', self::DEFAULT_BASE_URL );
+ $value = untrailingslashit( trim( $value ) );
+
+ if ( '' === $value || ! preg_match( '#^https?://#i', $value ) ) {
+ return self::DEFAULT_BASE_URL;
+ }
+
+ return $value;
+ }
+}
diff --git a/includes/class-cepis-woo-rest.php b/includes/class-cepis-woo-rest.php
new file mode 100644
index 0000000..561ca7e
--- /dev/null
+++ b/includes/class-cepis-woo-rest.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * The optional server-side proxy route.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Exposes `GET /wp-json/cepis/v1/lookup/{token}/{cep}`, which answers exactly
+ * what cep.is answers.
+ *
+ * The route is only registered when the store has turned proxy mode on. It is
+ * off by default because the browser can reach cep.is directly in about twenty
+ * milliseconds against a Cloudflare edge cache, and putting a WordPress
+ * bootstrap in front of that — on the checkout page, once per settled CEP —
+ * spends latency where a store can least afford it. Turn it on when a
+ * Content-Security-Policy forbids external XHR, or to get the server-side
+ * cache in {@see Cepis_Woo_Lookup}.
+ *
+ * The token is a path segment rather than a header because the widget's
+ * request is a plain cross-origin-safe GET with no custom headers.
+ * {@see Cepis_Woo_Token} explains why it is not a session-bound nonce.
+ */
+final class Cepis_Woo_Rest {
+
+ /**
+ * REST namespace for the proxy.
+ */
+ public const NAMESPACE_V1 = 'cepis/v1';
+
+ /**
+ * Register the route, when the store asked for it.
+ */
+ public static function init(): void {
+ if ( ! Cepis_Woo_Options::enabled() || 'proxy' !== Cepis_Woo_Options::mode() ) {
+ return;
+ }
+
+ add_action( 'rest_api_init', array( self::class, 'register' ) );
+ }
+
+ /**
+ * Declare the route.
+ */
+ public static function register(): void {
+ register_rest_route(
+ self::NAMESPACE_V1,
+ '/lookup/(?P<token>[A-Za-z0-9]{1,64})/(?P<cep>[0-9]{8})',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( self::class, 'lookup' ),
+ 'permission_callback' => array( self::class, 'permitted' ),
+ 'args' => array(
+ 'token' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'sanitize_callback' => 'sanitize_key',
+ ),
+ 'cep' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => static fn( $value ): bool => 1 === preg_match( '/^[0-9]{8}$/', (string) $value ),
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Reject anything that does not carry a live token.
+ *
+ * @param WP_REST_Request $request Incoming request.
+ *
+ * @return true|WP_Error True when the token checks out.
+ */
+ public static function permitted( WP_REST_Request $request ): bool|WP_Error {
+ if ( Cepis_Woo_Token::verify( (string) $request['token'] ) ) {
+ return true;
+ }
+
+ return new WP_Error(
+ 'cepis_invalid_token',
+ __( 'Lookup token missing, invalid or expired. Reload the page.', 'cepis-woocommerce' ),
+ array( 'status' => 403 )
+ );
+ }
+
+ /**
+ * Answer one lookup.
+ *
+ * @param WP_REST_Request $request Incoming request.
+ */
+ public static function lookup( WP_REST_Request $request ): WP_REST_Response {
+ $result = Cepis_Woo_Lookup::resolve( (string) $request['cep'] );
+
+ $response = new WP_REST_Response( $result['body'], $result['status'] );
+ $response->header( 'Cache-Control', 'no-store' );
+
+ if ( null !== $result['retry_after'] ) {
+ $response->header( 'Retry-After', (string) $result['retry_after'] );
+ }
+
+ return $response;
+ }
+
+ /**
+ * The API root to hand the browser, with a live token already in it.
+ *
+ * Shaped so that appending `/{cep}` to it reaches this route, which is what
+ * the widget does with its `baseUrl`.
+ */
+ public static function base_url(): string {
+ return untrailingslashit( rest_url( self::NAMESPACE_V1 . '/lookup/' . Cepis_Woo_Token::create() ) );
+ }
+}
diff --git a/includes/class-cepis-woo-settings.php b/includes/class-cepis-woo-settings.php
new file mode 100644
index 0000000..3fa73a1
--- /dev/null
+++ b/includes/class-cepis-woo-settings.php
@@ -0,0 +1,135 @@
+<?php
+/**
+ * The settings tab under WooCommerce.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * WooCommerce → Settings → CEP autofill.
+ *
+ * Extending `WC_Settings_Page` rather than rolling an options screen means the
+ * capability check, the `woocommerce-settings` nonce and the per-type
+ * sanitisation of every field come from WooCommerce, which is the code most
+ * likely to stay correct as WordPress changes. What is added on top is a
+ * second, explicit check on save and a stricter sanitiser for the one free-text
+ * field.
+ */
+final class Cepis_Woo_Settings extends WC_Settings_Page {
+
+ /**
+ * Identify the tab.
+ */
+ public function __construct() {
+ $this->id = 'cepis';
+ $this->label = __( 'CEP autofill', 'cepis-woocommerce' );
+
+ parent::__construct();
+
+ add_filter( 'woocommerce_admin_settings_sanitize_option_cepis_woo_base_url', array( $this, 'sanitize_base_url' ) );
+ }
+
+ /**
+ * The fields on the tab.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_settings_for_default_section(): array {
+ return array(
+ array(
+ 'title' => __( 'CEP autofill', 'cepis-woocommerce' ),
+ 'type' => 'title',
+ 'desc' => __( 'Fills the checkout address from the CEP the shopper types, using official Correios data (DNE) from cep.is.', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_section',
+ ),
+ array(
+ 'title' => __( 'Autofill', 'cepis-woocommerce' ),
+ 'desc' => __( 'Look the address up while the shopper types the CEP', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_enabled',
+ 'type' => 'checkbox',
+ 'default' => 'yes',
+ ),
+ array(
+ 'title' => __( 'Lookups go through', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_mode',
+ 'type' => 'radio',
+ 'default' => 'direct',
+ 'options' => array(
+ 'direct' => __( 'The shopper’s browser, straight to cep.is (recommended)', 'cepis-woocommerce' ),
+ 'proxy' => __( 'This site, through the WordPress REST API', 'cepis-woocommerce' ),
+ ),
+ 'desc_tip' => __( 'A direct lookup hits an edge cache and costs this store nothing. Route it through the site when a Content-Security-Policy forbids external requests from the page, or to cache answers server-side.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'Fields already filled in', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_overwrite',
+ 'type' => 'select',
+ 'class' => 'wc-enhanced-select',
+ 'default' => 'auto',
+ 'options' => array(
+ 'auto' => __( 'Keep what the shopper typed, replace what this plugin wrote', 'cepis-woocommerce' ),
+ 'never' => __( 'Only ever fill empty fields', 'cepis-woocommerce' ),
+ 'always' => __( 'Always overwrite', 'cepis-woocommerce' ),
+ ),
+ 'desc_tip' => __( 'A shopper who corrects the street by hand should not see the correction undone by the next lookup.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'House number', 'cepis-woocommerce' ),
+ 'desc' => __( 'Move the cursor to the house-number field after a successful lookup', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_focus_number',
+ 'type' => 'checkbox',
+ 'default' => 'yes',
+ 'desc_tip' => __( 'Classic checkout only, and only when the store has a house-number field.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'title' => __( 'API address', 'cepis-woocommerce' ),
+ 'id' => 'cepis_woo_base_url',
+ 'type' => 'url',
+ 'default' => Cepis_Woo_Options::DEFAULT_BASE_URL,
+ 'desc_tip' => __( 'Leave as it is unless you run your own instance of the API.', 'cepis-woocommerce' ),
+ ),
+ array(
+ 'type' => 'sectionend',
+ 'id' => 'cepis_woo_section',
+ ),
+ );
+ }
+
+ /**
+ * Save the tab.
+ *
+ * WooCommerce already gates this screen, but the check is repeated here so
+ * that the guarantee does not depend on a menu registration somewhere else
+ * staying correct.
+ */
+ public function save(): void {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ wp_die( esc_html__( 'You are not allowed to change these settings.', 'cepis-woocommerce' ), 403 );
+ }
+
+ check_admin_referer( 'woocommerce-settings' );
+
+ $previous = Cepis_Woo_Options::base_url();
+
+ parent::save();
+
+ if ( Cepis_Woo_Options::base_url() !== $previous ) {
+ Cepis_Woo_Lookup::flush();
+ }
+ }
+
+ /**
+ * Keep the API address to an absolute http(s) URL.
+ *
+ * @param mixed $value Whatever the form posted.
+ */
+ public function sanitize_base_url( mixed $value ): string {
+ $url = esc_url_raw( trim( (string) $value ), array( 'http', 'https' ) );
+
+ return '' === $url ? Cepis_Woo_Options::DEFAULT_BASE_URL : untrailingslashit( $url );
+ }
+}
diff --git a/includes/class-cepis-woo-token.php b/includes/class-cepis-woo-token.php
new file mode 100644
index 0000000..1d1b9f4
--- /dev/null
+++ b/includes/class-cepis-woo-token.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * The token that guards the server-side proxy route.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A short-lived nonce for the proxy route, derived the way WordPress derives
+ * its own but without binding to a login session.
+ *
+ * The bundled widget fetches with `credentials: "omit"` — deliberately, since
+ * a CEP lookup has no business carrying the shopper's cookies. The proxy
+ * therefore never sees an authentication cookie and every request reaches it
+ * anonymous, whether or not the shopper is logged in. A cookie-bound
+ * {@see wp_create_nonce()} would embed a session token in the page that the
+ * request could not present, so it would verify for guests and fail for
+ * everyone with an account: the worst possible failure mode, invisible in
+ * testing and broken in production.
+ *
+ * What the token has to defend against is the only threat this endpoint
+ * actually has — a third party pointing their own site at this store's proxy
+ * and using it as a free CEP gateway. A value tied to the site's salts and to
+ * {@see wp_nonce_tick()} does exactly that: it cannot be produced without the
+ * salts, and it stops working within a day.
+ *
+ * Same lifetime as a WordPress nonce: the current tick and the one before it
+ * are both accepted, so a checkout page held open overnight keeps working.
+ */
+final class Cepis_Woo_Token {
+
+ /**
+ * Namespaces the hash, so a token cannot be replayed against anything else
+ * on the site that hashes with the `nonce` salt.
+ */
+ private const ACTION = 'cepis_woo_proxy_lookup';
+
+ /**
+ * Mint the token to embed in the checkout page.
+ */
+ public static function create(): string {
+ return self::hash( wp_nonce_tick( self::ACTION ) );
+ }
+
+ /**
+ * Check a token presented by the browser.
+ *
+ * Compared with {@see hash_equals()} so that a wrong token takes the same
+ * time to reject as a right one.
+ *
+ * @param string $token Token as it arrived in the request.
+ */
+ public static function verify( string $token ): bool {
+ if ( '' === $token ) {
+ return false;
+ }
+
+ $tick = wp_nonce_tick( self::ACTION );
+
+ return hash_equals( self::hash( $tick ), $token )
+ || hash_equals( self::hash( $tick - 1 ), $token );
+ }
+
+ /**
+ * Derive the token for one tick.
+ *
+ * @param float $tick Nonce tick to derive from.
+ */
+ private static function hash( float $tick ): string {
+ return substr( wp_hash( $tick . '|' . self::ACTION, 'nonce' ), -12, 10 );
+ }
+}
diff --git a/includes/class-cepis-woo-transport.php b/includes/class-cepis-woo-transport.php
new file mode 100644
index 0000000..888be54
--- /dev/null
+++ b/includes/class-cepis-woo-transport.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * The bundled PHP client, taught to speak through the WordPress HTTP API.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A `Cepis\Transport` over {@see wp_remote_get()}.
+ *
+ * The library defaults to talking to curl directly, which is the wrong thing
+ * to do inside WordPress: a site's proxy configuration, its
+ * `http_request_args` filters, its request timeouts and everything a host adds
+ * on top of the HTTP API would all be bypassed. Routing through
+ * {@see wp_remote_get()} keeps this plugin a well-behaved guest.
+ *
+ * Exactly one request per call, no retrying — retries, backoff and
+ * `Retry-After` stay the client's job.
+ */
+final class Cepis_Woo_Transport implements Cepis\Transport {
+
+ /**
+ * Perform a single GET.
+ *
+ * @param string $url Absolute URL, query string included.
+ * @param array<string, string> $headers Request headers as name => value.
+ * @param float $timeout Seconds to allow for this attempt.
+ *
+ * @throws Cepis\NetworkException The request never completed.
+ */
+ public function get( string $url, array $headers, float $timeout ): Cepis\Response {
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'headers' => $headers,
+ 'timeout' => $timeout,
+ 'redirection' => 3,
+ 'sslverify' => true,
+ )
+ );
+
+ if ( is_wp_error( $response ) ) {
+ throw new Cepis\NetworkException( esc_html( $response->get_error_message() ) );
+ }
+
+ $response_headers = wp_remote_retrieve_headers( $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 )
+ );
+ }
+}
diff --git a/languages/cepis-woocommerce-pt_BR.mo b/languages/cepis-woocommerce-pt_BR.mo
new file mode 100644
index 0000000..961e3e8
--- /dev/null
+++ b/languages/cepis-woocommerce-pt_BR.mo
Binary files differ
diff --git a/languages/cepis-woocommerce-pt_BR.po b/languages/cepis-woocommerce-pt_BR.po
new file mode 100644
index 0000000..1169624
--- /dev/null
+++ b/languages/cepis-woocommerce-pt_BR.po
@@ -0,0 +1,116 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: CEP Autofill for WooCommerce 0.1.0\n"
+"Report-Msgid-Bugs-To: https://cep.is\n"
+"PO-Revision-Date: 2026-07-26 00:00+0000\n"
+"Last-Translator: Murilo Ijanc <[email protected]>\n"
+"Language-Team: Portugues do Brasil\n"
+"Language: pt_BR\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+msgid "Looking the address up…"
+msgstr "Buscando endereço…"
+
+msgid "Address found."
+msgstr "Endereço encontrado."
+
+msgid "CEP not found. Check the number or fill the address in by hand."
+msgstr "CEP não encontrado. Confira o número ou preencha o endereço manualmente."
+
+msgid "Invalid CEP. Type all 8 digits."
+msgstr "CEP inválido. Digite os 8 dígitos."
+
+msgid "Too many lookups in a short time. Wait a few seconds and try again."
+msgstr "Muitas consultas em pouco tempo. Aguarde alguns segundos e tente de novo."
+
+msgid "The CEP service is unavailable. Fill the address in by hand."
+msgstr "O serviço de CEP está indisponível. Preencha o endereço manualmente."
+
+msgid "Could not look the CEP up. Check your connection and try again."
+msgstr "Não foi possível consultar o CEP. Verifique sua conexão e tente de novo."
+
+msgid "The CEP lookup took too long. Try again or fill the address in by hand."
+msgstr "A consulta do CEP demorou demais. Tente de novo ou preencha manualmente."
+
+msgid "Unexpected answer from the CEP service. Fill the address in by hand."
+msgstr "Resposta inesperada do serviço de CEP. Preencha o endereço manualmente."
+
+msgid "Lookup token missing, invalid or expired. Reload the page."
+msgstr "Token de consulta ausente, inválido ou expirado. Recarregue a página."
+
+msgid "CEP autofill"
+msgstr "Preenchimento por CEP"
+
+msgid "Settings"
+msgstr "Configurações"
+
+msgid ""
+"Fills the checkout address from the CEP the shopper types, using official "
+"Correios data (DNE) from cep.is."
+msgstr ""
+"Preenche o endereço do checkout a partir do CEP digitado pelo cliente, com "
+"dados oficiais dos Correios (DNE) via cep.is."
+
+msgid "Autofill"
+msgstr "Preenchimento automático"
+
+msgid "Look the address up while the shopper types the CEP"
+msgstr "Consultar o endereço enquanto o cliente digita o CEP"
+
+msgid "Lookups go through"
+msgstr "As consultas passam por"
+
+msgid "The shopper’s browser, straight to cep.is (recommended)"
+msgstr "O navegador do cliente, direto no cep.is (recomendado)"
+
+msgid "This site, through the WordPress REST API"
+msgstr "Este site, pela API REST do WordPress"
+
+msgid ""
+"A direct lookup hits an edge cache and costs this store nothing. Route it "
+"through the site when a Content-Security-Policy forbids external requests "
+"from the page, or to cache answers server-side."
+msgstr ""
+"A consulta direta cai em cache de borda e não custa nada para a loja. Passe "
+"pelo site quando uma Content-Security-Policy proibir requisições externas na "
+"página, ou para guardar as respostas em cache no servidor."
+
+msgid "Fields already filled in"
+msgstr "Campos já preenchidos"
+
+msgid "Keep what the shopper typed, replace what this plugin wrote"
+msgstr "Manter o que o cliente digitou, substituir o que este plugin escreveu"
+
+msgid "Only ever fill empty fields"
+msgstr "Preencher apenas campos vazios"
+
+msgid "Always overwrite"
+msgstr "Sempre sobrescrever"
+
+msgid ""
+"A shopper who corrects the street by hand should not see the correction "
+"undone by the next lookup."
+msgstr ""
+"Quem corrige o logradouro na mão não deve perder a correção na consulta "
+"seguinte."
+
+msgid "House number"
+msgstr "Número"
+
+msgid "Move the cursor to the house-number field after a successful lookup"
+msgstr "Levar o cursor para o campo de número após uma consulta bem-sucedida"
+
+msgid "Classic checkout only, and only when the store has a house-number field."
+msgstr "Só no checkout clássico, e só quando a loja tem campo de número."
+
+msgid "API address"
+msgstr "Endereço da API"
+
+msgid "Leave as it is unless you run your own instance of the API."
+msgstr "Deixe como está, a menos que você rode sua própria instância da API."
+
+msgid "You are not allowed to change these settings."
+msgstr "Você não tem permissão para alterar estas configurações."
diff --git a/lib/cepis/Address.php b/lib/cepis/Address.php
new file mode 100644
index 0000000..8b970aa
--- /dev/null
+++ b/lib/cepis/Address.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * A CEP and everything the database knows about it.
+ *
+ * **Property names are the API's own JSON keys, in Portuguese** —
+ * `logradouro`, `bairro`, `localidade`. Everything else in this library is
+ * English; the data fields are the one deliberate exception, so that what you
+ * read in the JSON at https://cep.is/01001000 is exactly what you read on the
+ * object, with no translation layer and no field-by-field mapping to keep in
+ * your head. The values are Portuguese either way — they come from the
+ * Correios database.
+ *
+ * Fields the database does not have for that CEP come back as empty strings,
+ * never `null` — that is what the API returns.
+ *
+ * {@see Address::$geo} is the exception: it is `null` when there is no
+ * coordinate. There the difference is real, because the API **omits** the
+ * whole field in that case (about 43% of CEPs) rather than sending null.
+ *
+ * {@see Address::$raw} holds the response exactly as it arrived,
+ * uninterpreted, so a field the API grows before this library is updated is
+ * not lost.
+ */
+final class Address
+{
+ /**
+ * The JSON keys mapped onto properties, in the API's own order.
+ */
+ public const FIELDS = [
+ 'cep', 'logradouro', 'complemento', 'unidade', 'bairro', 'localidade',
+ 'uf', 'estado', 'regiao', 'ibge', 'gia', 'ddd', 'siafi',
+ ];
+
+ /**
+ * @param array<string, mixed> $raw
+ */
+ public function __construct(
+ public readonly string $cep = '',
+ public readonly string $logradouro = '',
+ public readonly string $complemento = '',
+ public readonly string $unidade = '',
+ public readonly string $bairro = '',
+ public readonly string $localidade = '',
+ public readonly string $uf = '',
+ public readonly string $estado = '',
+ public readonly string $regiao = '',
+ public readonly string $ibge = '',
+ public readonly string $gia = '',
+ public readonly string $ddd = '',
+ public readonly string $siafi = '',
+ public readonly ?Geo $geo = null,
+ public readonly array $raw = [],
+ ) {
+ }
+
+ /**
+ * Build from the decoded response.
+ *
+ * Unknown fields are ignored during construction but preserved in
+ * {@see Address::$raw}. A new field in the API therefore cannot break this
+ * library.
+ *
+ * @param array<string, mixed> $data
+ */
+ public static function fromArray(array $data): self
+ {
+ $values = [];
+ foreach (self::FIELDS as $name) {
+ $value = $data[$name] ?? '';
+ $values[$name] = is_scalar($value) ? (string) $value : '';
+ }
+
+ return new self(
+ cep: $values['cep'],
+ logradouro: $values['logradouro'],
+ complemento: $values['complemento'],
+ unidade: $values['unidade'],
+ bairro: $values['bairro'],
+ localidade: $values['localidade'],
+ uf: $values['uf'],
+ estado: $values['estado'],
+ regiao: $values['regiao'],
+ ibge: $values['ibge'],
+ gia: $values['gia'],
+ ddd: $values['ddd'],
+ siafi: $values['siafi'],
+ geo: Geo::fromArray($data['geo'] ?? null),
+ raw: $data,
+ );
+ }
+
+ /**
+ * Read any field of the raw response, including one the API grew after
+ * this release, or `null` when it is absent.
+ */
+ public function field(string $name): ?string
+ {
+ $value = $this->raw[$name] ?? null;
+
+ return is_scalar($value) ? (string) $value : null;
+ }
+
+ /**
+ * The CEP with its mask: `01001-000`.
+ *
+ * The API always returns it unmasked; formatting is presentation, and it
+ * lives here so it does not become a repeated `sprintf` in every project
+ * that uses this.
+ */
+ public function formattedCep(): string
+ {
+ return substr($this->cep, 0, 5) . '-' . substr($this->cep, 5, 3);
+ }
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ $out = [];
+ foreach (self::FIELDS as $name) {
+ $out[$name] = $this->{$name};
+ }
+ $out['geo'] = $this->geo?->toArray();
+
+ return $out;
+ }
+}
diff --git a/lib/cepis/CepNotFoundException.php b/lib/cepis/CepNotFoundException.php
new file mode 100644
index 0000000..c64de8b
--- /dev/null
+++ b/lib/cepis/CepNotFoundException.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP is well-formed but does not exist in the DNE database.
+ *
+ * Extends `\OutOfBoundsException`, the SPL type for a value that is not a
+ * valid key: looking a CEP up is conceptually a key lookup.
+ */
+final class CepNotFoundException extends \OutOfBoundsException implements CepisException
+{
+}
diff --git a/lib/cepis/CepisException.php b/lib/cepis/CepisException.php
new file mode 100644
index 0000000..efd1a86
--- /dev/null
+++ b/lib/cepis/CepisException.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * Implemented by every exception this library throws. Catch it to handle any
+ * failure with one handler.
+ *
+ * It is an interface rather than a base class so that each concrete exception
+ * can also extend the SPL type a PHP developer would already be catching:
+ * {@see InvalidCepException} is an `\InvalidArgumentException`, so code that
+ * already validates input keeps working without knowing this library exists,
+ * and {@see CepNotFoundException} is an `\OutOfBoundsException`, because
+ * looking a CEP up is conceptually a key lookup. PHP has single inheritance;
+ * a marker interface is the only way to have both.
+ *
+ * Below this interface the split is by **the decision the caller has to
+ * make**, not by the HTTP status that caused it:
+ *
+ * - {@see InvalidCepException} and {@see CepNotFoundException} are problems
+ * with the *request*. Retrying is pointless — the same CEP will fail again.
+ * - {@see NetworkException}, {@see ServerErrorException} and
+ * {@see RateLimitedException} are problems with *transport*. Retrying may
+ * help, and the client already retried on its own before throwing.
+ *
+ * That split is why `CepNotFoundException` is not "the HTTP 404 exception":
+ * `InvalidCepException` is thrown without any request being made at all, when
+ * the CEP is malformed before it ever leaves the machine.
+ */
+interface CepisException extends \Throwable
+{
+}
diff --git a/lib/cepis/Client.php b/lib/cepis/Client.php
new file mode 100644
index 0000000..4e24b7f
--- /dev/null
+++ b/lib/cepis/Client.php
@@ -0,0 +1,549 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * Client for the public CEP API at https://cep.is.
+ *
+ * Reuse one instance: the configuration is immutable and no method keeps state
+ * between calls.
+ *
+ * ```php
+ * $client = new Cepis\Client(timeout: 5.0, totalTimeout: 15.0);
+ * echo $client->lookup('01001-000')->localidade; // São Paulo
+ * ```
+ *
+ * For a script, {@see Client::default()} is the shared, default-configured
+ * instance:
+ *
+ * ```php
+ * echo Cepis\Client::default()->lookup('01001-000')->uf; // SP
+ * ```
+ *
+ * Retries cover 429, the transient 5xx in {@see Client::RETRIABLE_STATUS} and
+ * network failures, with exponential backoff and full jitter. 400 and 404 are
+ * deliberately absent from that set: both are definitive answers about the
+ * request, so retrying returns the same thing more slowly while burning
+ * someone else's quota.
+ */
+final class Client
+{
+ public const VERSION = '0.1.0';
+
+ /**
+ * Where the API lives, unless `baseUrl` says otherwise.
+ */
+ public const DEFAULT_URL = 'https://cep.is';
+
+ /**
+ * Most CEPs the API accepts in a single batch request.
+ */
+ public const MAX_BATCH = 500;
+
+ /**
+ * Statuses worth trying again.
+ */
+ public const RETRIABLE_STATUS = [429, 500, 502, 503, 504];
+
+ /**
+ * Where the API lives. A trailing slash is stripped, so it cannot produce
+ * doubled separators that some proxies normalise and others 404.
+ */
+ public readonly string $baseUrl;
+
+ /**
+ * Identifies the library and its version to the server.
+ */
+ public readonly string $userAgent;
+
+ /**
+ * How the GET is actually performed. {@see CurlTransport} unless the
+ * caller supplied one.
+ */
+ public readonly Transport $transport;
+
+ private static ?self $default = null;
+
+ /**
+ * @param string $baseUrl Where the API lives. Point it at a local instance
+ * (the API repo brings one up on `http://localhost:8080` with
+ * `just up`) or at a test server. It is the only setting that usually
+ * changes between environments.
+ * @param float $timeout Seconds to allow for **a single attempt** —
+ * connection, response and body read together. It is not a cap on the
+ * whole call; that cap is `$totalTimeout`.
+ * @param int $maxAttempts How many attempts in total, counting the first.
+ * `1` disables retrying. The default of `3` covers the common case
+ * (one transient failure) without turning a real outage into a long
+ * wait.
+ * @param float $backoffBase Base of the exponential wait, in seconds. The
+ * actual wait is drawn from `[0, base * 2 ** (n - 1)]` — *full
+ * jitter*. The randomness is not decoration: without it, N clients
+ * that fail together come back together and reproduce the spike that
+ * took the service down.
+ * @param float $backoffMax Cap on each individual wait, in seconds. It
+ * applies to a server-sent `Retry-After` too, or a
+ * `Retry-After: 3600` from a misconfigured proxy would park the
+ * process for an hour.
+ * @param float|null $totalTimeout Cap on the whole call, attempts and
+ * waits included. `null` leaves `$maxAttempts` as the only bound. Set
+ * it in code serving a web request: without it the worst case is
+ * `$maxAttempts * $timeout` plus the sum of the backoffs.
+ * @param string|null $userAgent Overrides the `User-Agent`. The default
+ * identifies the library and its version, which is what lets the
+ * server diagnose an old client misbehaving.
+ * @param Transport|null $transport Overrides how the GET is performed.
+ * Supply one to route requests through the host application's HTTP
+ * layer — a WordPress plugin passes an implementation over
+ * `wp_remote_get`. `null` uses curl directly.
+ *
+ * @throws \InvalidArgumentException a setting is out of range.
+ */
+ public function __construct(
+ string $baseUrl = self::DEFAULT_URL,
+ public readonly float $timeout = 10.0,
+ public readonly int $maxAttempts = 3,
+ public readonly float $backoffBase = 0.5,
+ public readonly float $backoffMax = 8.0,
+ public readonly ?float $totalTimeout = null,
+ ?string $userAgent = null,
+ ?Transport $transport = null,
+ ) {
+ if ($maxAttempts < 1) {
+ throw new \InvalidArgumentException('maxAttempts must be >= 1');
+ }
+ if ($timeout <= 0) {
+ throw new \InvalidArgumentException('timeout must be > 0');
+ }
+ if ($backoffBase < 0 || $backoffMax < 0) {
+ throw new \InvalidArgumentException('backoffBase and backoffMax must be >= 0');
+ }
+
+ $this->baseUrl = rtrim($baseUrl, '/');
+ $this->userAgent = $userAgent ?? sprintf('cepis-php/%s (+https://cep.is)', self::VERSION);
+ $this->transport = $transport ?? new CurlTransport();
+ }
+
+ /**
+ * The shared, default-configured client.
+ *
+ * Built once because {@see Client} keeps no state between calls. It
+ * applies process-wide, third-party libraries that also use this package
+ * included. Anyone needing different settings should build their own with
+ * `new Client(...)`.
+ */
+ public static function default(): self
+ {
+ return self::$default ??= new self();
+ }
+
+ /**
+ * Return the CEP's 8 digits, accepting the mask.
+ *
+ * `"01001-000"`, `"01001000"` and `" 01001-000 "` all become
+ * `"01001000"`.
+ *
+ * This runs **before** any request: a 7-digit CEP is the caller's bug, and
+ * spending a round trip for the server to say so wastes both sides' time.
+ *
+ * @throws InvalidCepException unless exactly 8 digits remain.
+ */
+ public static function normalizeCep(string $value): string
+ {
+ $cleaned = str_replace(['-', '.', ' '], '', trim($value));
+ if (preg_match('/^\d{8}$/', $cleaned) !== 1) {
+ throw new InvalidCepException(sprintf('CEP must be 8 digits, got "%s"', $value));
+ }
+
+ return $cleaned;
+ }
+
+ /**
+ * Look up a single CEP, masked or unmasked.
+ *
+ * ```php
+ * $client->lookup('01001-000')->localidade; // "São Paulo"
+ * ```
+ *
+ * A 400 from the API is surfaced as {@see InvalidCepException} carrying
+ * the API's own message: the format already passed local validation, so
+ * the server knows something this client does not.
+ *
+ * @throws InvalidCepException the CEP is not 8 digits (no request is made).
+ * @throws CepNotFoundException well-formed, but not in the database.
+ * @throws RateLimitedException 429 after every attempt.
+ * @throws ServerErrorException 5xx after every attempt.
+ * @throws NetworkException the request never completed.
+ * @throws InvalidResponseException a success status with an unreadable body.
+ */
+ public function lookup(string $cep): Address
+ {
+ $number = self::normalizeCep($cep);
+ [$status, $body] = $this->request('/' . $number);
+
+ if ($status === 404) {
+ throw new CepNotFoundException(
+ sprintf('CEP %s does not exist in the DNE database', $number),
+ );
+ }
+ if ($status === 400) {
+ throw new InvalidCepException(
+ self::errorMessage($body, sprintf('CEP %s rejected', $number)),
+ );
+ }
+
+ return Address::fromArray($this->decodeJsonObject($body));
+ }
+
+ /**
+ * Look up many CEPs in one batch.
+ *
+ * Returns an array of **the same length and in the same order** as the
+ * input, re-indexed from zero. A CEP that does not exist becomes `null` at
+ * its position — it does not vanish from the array, and it does not throw.
+ * That is the right difference from {@see Client::lookup()}: in a batch of
+ * a thousand, one bad CEP must not cost the other nine hundred and
+ * ninety-nine.
+ *
+ * A malformed CEP also becomes `null`, for the same reason, rather than
+ * throwing {@see InvalidCepException}. To know *which* one was malformed,
+ * validate first with {@see Client::normalizeCep()}.
+ *
+ * Input is chunked into blocks of {@see Client::MAX_BATCH} (the API's
+ * limit) automatically. Do not loop over {@see Client::lookup()}: a loop of
+ * 500 calls pays 500 TLS handshakes, the batch pays one.
+ *
+ * **Batches carry no coordinate.** The endpoint behind them is CSV, and
+ * the CSV has no `geo` column. Every {@see Address} returned here has a
+ * `null` `geo`, including for CEPs that do have one. Need the coordinate?
+ * Use {@see Client::lookup()}, one at a time.
+ *
+ * @param list<string> $ceps
+ *
+ * @return list<Address|null>
+ */
+ public function lookupMany(array $ceps): array
+ {
+ if ($ceps === []) {
+ return [];
+ }
+
+ $result = [];
+ foreach (array_chunk(array_values($ceps), self::MAX_BATCH) as $chunk) {
+ foreach ($this->lookupChunk($chunk) as $address) {
+ $result[] = $address;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Query `GET /health`: does the API answer, and with which database.
+ *
+ * Does not throw for an API reporting itself degraded — that arrives in
+ * {@see Health::$status}, and checking health is exactly when you want the
+ * diagnosis rather than an exception. Network failures still throw.
+ */
+ public function health(): Health
+ {
+ [, $body] = $this->request('/health', [200, 503]);
+
+ return Health::fromArray($this->decodeJsonObject($body));
+ }
+
+ /**
+ * One chunk of at most {@see Client::MAX_BATCH} CEPs, in a single request.
+ *
+ * A blank CSV row means "not found": the API fills only the `cep` column
+ * and leaves the rest empty, precisely so it does not misalign whoever is
+ * consuming the batch in a spreadsheet.
+ *
+ * The API guarantees one row per requested CEP. Should that ever fail to
+ * hold — a proxy truncating, a cut response — the result is padded to the
+ * input length rather than returned short, because a shifted array
+ * silently pairs the wrong address with the wrong CEP.
+ *
+ * @param list<string> $ceps
+ *
+ * @return list<Address|null>
+ */
+ private function lookupChunk(array $ceps): array
+ {
+ $query = http_build_query(['ceps' => implode(',', $ceps)]);
+ [, $body] = $this->request('/csv?' . $query);
+
+ $rows = [];
+ foreach ($this->parseCsv($body) as $row) {
+ $rows[] = trim($row['localidade'] ?? '') === '' ? null : Address::fromArray($row);
+ }
+
+ $wanted = count($ceps);
+ if (count($rows) !== $wanted) {
+ $rows = array_slice(array_pad($rows, $wanted, null), 0, $wanted);
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Parse the CSV batch body into one associative row per record.
+ *
+ * `fgetcsv` over an in-memory stream is PHP's own RFC 4180 parser: it
+ * handles the quoted fields the API emits for values containing a comma,
+ * the `""` escape inside them, and a quoted field spanning a newline —
+ * which splitting on `\n` and calling `str_getcsv` per line would not.
+ *
+ * The empty `$escape` argument is required for RFC 4180: PHP's historical
+ * default treats a backslash as an escape character, which would swallow
+ * the closing quote of a field ending in one.
+ *
+ * @return list<array<string, string>>
+ */
+ private function parseCsv(string $body): array
+ {
+ $text = $body;
+ if (str_starts_with($text, "\xEF\xBB\xBF")) {
+ $text = substr($text, 3);
+ }
+
+ $stream = fopen('php://temp', 'r+');
+ if ($stream === false) {
+ throw new InvalidResponseException('could not open a stream to parse the CSV');
+ }
+
+ try {
+ fwrite($stream, $text);
+ rewind($stream);
+
+ $header = fgetcsv($stream, null, ',', '"', '');
+ if (!is_array($header) || $header === [null]) {
+ throw new InvalidResponseException(
+ 'the batch response has no CSV header row: '
+ . var_export(substr($body, 0, 120), true),
+ );
+ }
+ $header = array_map(static fn ($name): string => trim((string) $name), $header);
+ $width = count($header);
+
+ $rows = [];
+ while (($record = fgetcsv($stream, null, ',', '"', '')) !== false) {
+ if (!is_array($record) || $record === [null]) {
+ continue;
+ }
+ $record = array_slice(array_pad($record, $width, ''), 0, $width);
+ $rows[] = array_combine(
+ $header,
+ array_map(static fn ($value): string => (string) ($value ?? ''), $record),
+ );
+ }
+
+ return $rows;
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ /**
+ * Decode a JSON object body, or throw {@see InvalidResponseException}.
+ *
+ * @return array<string, mixed>
+ */
+ private function decodeJsonObject(string $body): array
+ {
+ try {
+ $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ } catch (\JsonException $exception) {
+ throw new InvalidResponseException(
+ 'response is not valid JSON — something between you and the API is '
+ . 'answering instead of it: ' . var_export(substr($body, 0, 120), true),
+ 0,
+ $exception,
+ );
+ }
+
+ if (!is_array($data) || (array_is_list($data) && $data !== [])) {
+ throw new InvalidResponseException(
+ 'expected a JSON object, got ' . get_debug_type($data),
+ );
+ }
+
+ return $data;
+ }
+
+ /**
+ * Pull `mensagem` out of an error body, falling back to a default.
+ */
+ private static function errorMessage(string $body, string $fallback): string
+ {
+ $data = json_decode($body, true);
+ if (is_array($data) && isset($data['mensagem']) && is_scalar($data['mensagem'])) {
+ $message = (string) $data['mensagem'];
+ if ($message !== '') {
+ return $message;
+ }
+ }
+
+ return $fallback;
+ }
+
+ /**
+ * Parse the `Retry-After` header in both RFC 9110 forms.
+ *
+ * It may arrive as a number of seconds (`"120"`) or as an HTTP date
+ * (`"Wed, 21 Oct 2026 07:28:00 GMT"`). Handling only the first is the
+ * common bug, and it makes a client ignore exactly the wait request that
+ * matters most.
+ */
+ private static function retryAfterSeconds(?string $value): ?float
+ {
+ if ($value === null) {
+ return null;
+ }
+ $value = trim($value);
+ if ($value === '') {
+ return null;
+ }
+ if (is_numeric($value)) {
+ return max(0.0, (float) $value);
+ }
+
+ $target = strtotime($value);
+ if ($target === false) {
+ return null;
+ }
+
+ return max(0.0, (float) $target - microtime(true));
+ }
+
+ /**
+ * How long to wait before the next attempt.
+ *
+ * The server's `Retry-After` wins over the local calculation when present:
+ * it knows when it will be back, we are guessing. It still goes through
+ * the `backoffMax` cap. Otherwise the wait is drawn from `[0, ceiling]`
+ * rather than being the ceiling — see `$backoffBase` on why that matters.
+ */
+ private function backoff(int $attempt, ?float $retryAfter): float
+ {
+ if ($retryAfter !== null) {
+ return min($retryAfter, $this->backoffMax);
+ }
+
+ $ceiling = min($this->backoffBase * (2 ** ($attempt - 1)), $this->backoffMax);
+
+ return (mt_rand() / mt_getrandmax()) * $ceiling;
+ }
+
+ /**
+ * Perform the GET, with retries. Returns `[status, body]`.
+ *
+ * *Definitive* error statuses (400, 404) are returned normally, for the
+ * caller to turn into the right exception — only transient failures become
+ * exceptions here, and only once the attempts are exhausted.
+ *
+ * @param list<int> $acceptedStatus
+ *
+ * @return array{0: int, 1: string}
+ */
+ private function request(string $path, array $acceptedStatus = [200]): array
+ {
+ $url = $this->baseUrl . $path;
+ $headers = [
+ 'User-Agent' => $this->userAgent,
+ 'Accept' => 'application/json, text/csv;q=0.9, */*;q=0.1',
+ 'Accept-Encoding' => 'identity',
+ ];
+ $deadline = $this->totalTimeout === null ? null : self::monotonic() + $this->totalTimeout;
+
+ $lastStatus = null;
+ $lastRetryAfter = null;
+ $lastNetworkError = null;
+
+ for ($attempt = 1; $attempt <= $this->maxAttempts; $attempt++) {
+ if ($deadline !== null && self::monotonic() >= $deadline) {
+ break;
+ }
+
+ try {
+ $response = $this->transport->get($url, $headers, $this->timeout);
+
+ if (
+ in_array($response->status, $acceptedStatus, true)
+ || !in_array($response->status, self::RETRIABLE_STATUS, true)
+ ) {
+ return [$response->status, $response->body];
+ }
+
+ $lastStatus = $response->status;
+ $lastRetryAfter = self::retryAfterSeconds($response->header('Retry-After'));
+ $lastNetworkError = null;
+ } catch (NetworkException $exception) {
+ $lastNetworkError = $exception;
+ $lastStatus = null;
+ $lastRetryAfter = null;
+ }
+
+ if ($attempt === $this->maxAttempts) {
+ break;
+ }
+
+ $wait = $this->backoff($attempt, $lastRetryAfter);
+ if ($deadline !== null) {
+ $remaining = $deadline - self::monotonic();
+ if ($remaining <= 0) {
+ break;
+ }
+ $wait = min($wait, $remaining);
+ }
+ if ($wait > 0) {
+ usleep((int) round($wait * 1_000_000));
+ }
+ }
+
+ $this->fail($url, $lastStatus, $lastRetryAfter, $lastNetworkError);
+ }
+
+ /**
+ * Turn the last failure into the exception that says what to do about it.
+ */
+ private function fail(
+ string $url,
+ ?int $status,
+ ?float $retryAfter,
+ ?NetworkException $networkError,
+ ): never {
+ $attempts = sprintf('(after %d attempt(s))', $this->maxAttempts);
+
+ if ($status === 429) {
+ throw new RateLimitedException(
+ sprintf('rate limit reached at %s %s', $url, $attempts),
+ $retryAfter,
+ );
+ }
+ if ($status !== null) {
+ throw new ServerErrorException(
+ sprintf('the API answered %d at %s %s', $status, $url, $attempts),
+ $status,
+ );
+ }
+
+ throw new NetworkException(
+ sprintf(
+ 'could not reach %s %s: %s',
+ $url,
+ $attempts,
+ $networkError?->getMessage() ?? 'the call ran out of time before any attempt',
+ ),
+ 0,
+ $networkError,
+ );
+ }
+
+ private static function monotonic(): float
+ {
+ return hrtime(true) / 1_000_000_000;
+ }
+}
diff --git a/lib/cepis/CurlTransport.php b/lib/cepis/CurlTransport.php
new file mode 100644
index 0000000..d22949b
--- /dev/null
+++ b/lib/cepis/CurlTransport.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The default {@see Transport}, built on ext-curl.
+ *
+ * curl is used directly rather than through an HTTP abstraction: this library
+ * makes one GET, and a wrapper for that would cost dependencies without buying
+ * anything. Hosts that need their own HTTP layer implement {@see Transport}
+ * instead.
+ *
+ * Each call opens its own handle, so one instance is safe to share.
+ */
+final class CurlTransport implements Transport
+{
+ /**
+ * @param bool $verifyTls Whether to verify the peer's certificate. Leave
+ * it alone. It exists for a lab against a self-signed local instance,
+ * and turning it off on the public API removes the only thing making
+ * the answer trustworthy.
+ * @param int $maxRedirects How many redirects to follow.
+ */
+ public function __construct(
+ public readonly bool $verifyTls = true,
+ public readonly int $maxRedirects = 3,
+ ) {
+ }
+
+ public function get(string $url, array $headers, float $timeout): Response
+ {
+ $handle = curl_init();
+ if ($handle === false) {
+ throw new NetworkException('could not initialise a curl handle');
+ }
+
+ $lines = [];
+ foreach ($headers as $name => $value) {
+ $lines[] = $name . ': ' . $value;
+ }
+
+ $milliseconds = max(1, (int) round($timeout * 1000));
+ $received = [];
+
+ curl_setopt_array($handle, [
+ CURLOPT_URL => $url,
+ CURLOPT_HTTPGET => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => $lines,
+ CURLOPT_CONNECTTIMEOUT_MS => $milliseconds,
+ CURLOPT_TIMEOUT_MS => $milliseconds,
+ CURLOPT_FOLLOWLOCATION => $this->maxRedirects > 0,
+ CURLOPT_MAXREDIRS => $this->maxRedirects,
+ CURLOPT_SSL_VERIFYPEER => $this->verifyTls,
+ CURLOPT_SSL_VERIFYHOST => $this->verifyTls ? 2 : 0,
+ CURLOPT_NOSIGNAL => true,
+ CURLOPT_HEADERFUNCTION => static function ($_handle, string $line) use (&$received): int {
+ $length = strlen($line);
+ $parts = explode(':', $line, 2);
+ if (count($parts) === 2) {
+ $received[trim($parts[0])][] = trim($parts[1]);
+ }
+
+ return $length;
+ },
+ ]);
+
+ $body = curl_exec($handle);
+ $error = curl_error($handle);
+ $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
+ unset($handle);
+
+ if ($body === false || $status === 0) {
+ throw new NetworkException(sprintf(
+ 'could not reach %s: %s',
+ $url,
+ $error !== '' ? $error : 'the request did not complete',
+ ));
+ }
+
+ return new Response($status, $received, (string) $body);
+ }
+}
diff --git a/lib/cepis/Geo.php b/lib/cepis/Geo.php
new file mode 100644
index 0000000..c677622
--- /dev/null
+++ b/lib/cepis/Geo.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP's coordinate, when the database has one.
+ *
+ * The point is the **median** of the coordinates of that CEP's addresses, not
+ * the mean: one badly geocoded address 40 km away shifts a mean and does not
+ * move a median.
+ *
+ * Always check {@see Geo::$precisao} before using the coordinate for distance:
+ *
+ * - `"logradouro"`: the point is a stretch of street. `raio_m` lands around
+ * 100–150 m. Good for maps and for distance.
+ * - `"localidade"`: the CEP *is* the whole municipality (a city-wide general
+ * CEP), and the point is its centroid. `raio_m` runs from 7 km to 40 km.
+ * Computing shipping from this is wrong by tens of kilometres.
+ *
+ * `raio_m` is the radius containing 90% of that CEP's addresses. It exists
+ * precisely so that error cannot pass unnoticed.
+ */
+final class Geo
+{
+ /**
+ * Property names are the API's own JSON keys, `raio_m` included. See
+ * {@see Address} for why they are not translated or camel-cased.
+ *
+ * @param string $precisao Kept a plain string rather than an enum so a
+ * precision level the API adds later cannot break this client.
+ */
+ public function __construct(
+ public readonly float $lat,
+ public readonly float $lon,
+ public readonly int $raio_m,
+ public readonly string $precisao,
+ ) {
+ }
+
+ /**
+ * Build from the decoded `geo` block, or `null` when it is missing or
+ * malformed.
+ *
+ * A malformed `geo` is not worth losing the whole address over, since the
+ * rest of the response is still useful.
+ */
+ public static function fromArray(mixed $data): ?self
+ {
+ if (!is_array($data)) {
+ return null;
+ }
+ foreach (['lat', 'lon', 'raio_m', 'precisao'] as $key) {
+ if (!isset($data[$key]) || !is_scalar($data[$key])) {
+ return null;
+ }
+ }
+ if (!is_numeric($data['lat']) || !is_numeric($data['lon']) || !is_numeric($data['raio_m'])) {
+ return null;
+ }
+
+ return new self(
+ lat: (float) $data['lat'],
+ lon: (float) $data['lon'],
+ raio_m: (int) (float) $data['raio_m'],
+ precisao: (string) $data['precisao'],
+ );
+ }
+
+ /**
+ * `true` when the coordinate points at a street, not a municipality.
+ *
+ * Shorthand for `precisao === "logradouro"`, which is the check to make
+ * before using the point to measure distance.
+ */
+ public function isPrecise(): bool
+ {
+ return $this->precisao === 'logradouro';
+ }
+
+ /**
+ * @return array{lat: float, lon: float, raio_m: int, precisao: string}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'lat' => $this->lat,
+ 'lon' => $this->lon,
+ 'raio_m' => $this->raio_m,
+ 'precisao' => $this->precisao,
+ ];
+ }
+}
diff --git a/lib/cepis/Health.php b/lib/cepis/Health.php
new file mode 100644
index 0000000..08f7547
--- /dev/null
+++ b/lib/cepis/Health.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The response of `GET /health`.
+ *
+ * Good for two things: checking that the API answers, and knowing **which
+ * version of the database** is live. The DNE database changes every two weeks;
+ * {@see Health::$dne} identifies it.
+ */
+final class Health
+{
+ /**
+ * Property names are the API's own JSON keys, `base_gerada_em` included.
+ * See {@see Address} for why they are not translated or camel-cased.
+ */
+ public function __construct(
+ public readonly string $status,
+ public readonly string $dne,
+ public readonly int $ceps,
+ public readonly int $geo,
+ public readonly string $base_gerada_em,
+ ) {
+ }
+
+ /**
+ * Build from the decoded response.
+ *
+ * @param array<string, mixed> $data
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ status: self::asString($data['status'] ?? ''),
+ dne: self::asString($data['dne'] ?? ''),
+ ceps: self::asInt($data['ceps'] ?? 0),
+ geo: self::asInt($data['geo'] ?? 0),
+ base_gerada_em: self::asString($data['base_gerada_em'] ?? ''),
+ );
+ }
+
+ /**
+ * `true` when the API reports itself healthy.
+ */
+ public function isOk(): bool
+ {
+ return $this->status === 'ok';
+ }
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function toArray(): array
+ {
+ return [
+ 'status' => $this->status,
+ 'dne' => $this->dne,
+ 'ceps' => $this->ceps,
+ 'geo' => $this->geo,
+ 'base_gerada_em' => $this->base_gerada_em,
+ ];
+ }
+
+ private static function asString(mixed $value): string
+ {
+ return is_scalar($value) ? (string) $value : '';
+ }
+
+ private static function asInt(mixed $value): int
+ {
+ return is_numeric($value) ? (int) (float) $value : 0;
+ }
+}
diff --git a/lib/cepis/InvalidCepException.php b/lib/cepis/InvalidCepException.php
new file mode 100644
index 0000000..21a4593
--- /dev/null
+++ b/lib/cepis/InvalidCepException.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The CEP is not 8 digits.
+ *
+ * Usually thrown **without** a request ever being made — the client checks the
+ * format before going to the network. It is also thrown when the API answers
+ * 400 to a CEP that passed local validation, in which case it carries the
+ * API's own message.
+ *
+ * Extends `\InvalidArgumentException`: this is an argument error.
+ */
+final class InvalidCepException extends \InvalidArgumentException implements CepisException
+{
+}
diff --git a/lib/cepis/InvalidResponseException.php b/lib/cepis/InvalidResponseException.php
new file mode 100644
index 0000000..ee77984
--- /dev/null
+++ b/lib/cepis/InvalidResponseException.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * A success status came back with a body that cannot be parsed.
+ *
+ * In practice this means something between you and the API is answering
+ * instead of it — a captive Wi-Fi portal, a corporate proxy, an HTML error
+ * page. Useful as a diagnosis: the problem is not in your code.
+ */
+final class InvalidResponseException extends \RuntimeException implements CepisException
+{
+}
diff --git a/lib/cepis/NetworkException.php b/lib/cepis/NetworkException.php
new file mode 100644
index 0000000..506b615
--- /dev/null
+++ b/lib/cepis/NetworkException.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The request never completed: DNS, refused connection, timeout, TLS.
+ *
+ * A {@see Transport} signals a failed request by throwing this. The client
+ * treats it as retriable and only lets it escape once every configured attempt
+ * has failed.
+ */
+final class NetworkException extends \RuntimeException implements CepisException
+{
+}
diff --git a/lib/cepis/RateLimitedException.php b/lib/cepis/RateLimitedException.php
new file mode 100644
index 0000000..579fe7b
--- /dev/null
+++ b/lib/cepis/RateLimitedException.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The API (or the edge in front of it) answered 429 on every attempt.
+ */
+final class RateLimitedException extends \RuntimeException implements CepisException
+{
+ /**
+ * @param float|null $retryAfter What the server asked for, in seconds, or
+ * `null` if it did not say. The client already honoured this across
+ * the attempts it made; it is exposed for callers who want to
+ * reschedule the work.
+ */
+ public function __construct(
+ string $message,
+ public readonly ?float $retryAfter = null,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
+}
diff --git a/lib/cepis/Response.php b/lib/cepis/Response.php
new file mode 100644
index 0000000..f0ecb8d
--- /dev/null
+++ b/lib/cepis/Response.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * What a {@see Transport} hands back: an HTTP status, the response headers and
+ * the body as a string.
+ *
+ * Deliberately not a PSR-7 message. This library makes one GET and needs three
+ * things out of it; a plain value object keeps the {@see Transport} interface
+ * implementable in a few lines against whatever HTTP layer the host
+ * application already has.
+ */
+final class Response
+{
+ /**
+ * @param array<string, string|list<string>> $headers Response headers.
+ * Keys may be in any case and values may be a single string or a list
+ * of them; {@see Response::header()} handles both, so an implementer
+ * can pass through whatever their HTTP layer produced.
+ */
+ public function __construct(
+ public readonly int $status,
+ public readonly array $headers = [],
+ public readonly string $body = '',
+ ) {
+ }
+
+ /**
+ * Look a header up case-insensitively, returning the first value or `null`
+ * when it is absent.
+ */
+ public function header(string $name): ?string
+ {
+ foreach ($this->headers as $key => $value) {
+ if (strcasecmp((string) $key, $name) !== 0) {
+ continue;
+ }
+ if (is_array($value)) {
+ $value = $value[array_key_first($value)] ?? null;
+ }
+
+ return is_scalar($value) ? (string) $value : null;
+ }
+
+ return null;
+ }
+}
diff --git a/lib/cepis/ServerErrorException.php b/lib/cepis/ServerErrorException.php
new file mode 100644
index 0000000..731b23b
--- /dev/null
+++ b/lib/cepis/ServerErrorException.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * The API answered 5xx on every attempt.
+ */
+final class ServerErrorException extends \RuntimeException implements CepisException
+{
+ /**
+ * @param int $status HTTP status seen on the last attempt.
+ */
+ public function __construct(
+ string $message,
+ public readonly int $status,
+ ?\Throwable $previous = null,
+ ) {
+ parent::__construct($message, 0, $previous);
+ }
+}
diff --git a/lib/cepis/Transport.php b/lib/cepis/Transport.php
new file mode 100644
index 0000000..811ac69
--- /dev/null
+++ b/lib/cepis/Transport.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Cepis;
+
+/**
+ * How the client actually performs a GET.
+ *
+ * The default is {@see CurlTransport}, and most callers never touch this.
+ * It exists because some hosts require their own HTTP layer: a WordPress
+ * plugin is expected to go through the WP HTTP API (`wp_remote_get`) so that
+ * site owners keep proxy support and the `http_request_args` filters they rely
+ * on, and a library hardcoding curl cannot be a good citizen there.
+ *
+ * One method, no PSR-7, no framework types: an implementation over
+ * `wp_remote_get` is about fifteen lines. See the README for that example.
+ *
+ * Implementations must be safe to reuse across calls; the client holds one for
+ * its lifetime.
+ */
+interface Transport
+{
+ /**
+ * Perform a GET and return the response, whatever its status.
+ *
+ * A non-2xx status is **not** an error here — return it and let the client
+ * decide whether it is retriable. Throw {@see NetworkException} only when
+ * the request did not complete at all: DNS failure, refused connection,
+ * timeout, TLS error.
+ *
+ * Retries, backoff and `Retry-After` are the client's job. An
+ * implementation should make exactly one request and should not retry.
+ *
+ * @param string $url Absolute URL, query string included.
+ * @param array<string, string> $headers Request headers to send, as
+ * name => value.
+ * @param float $timeout Seconds to allow for this single
+ * attempt. Implementations that distinguish connect from read timeouts
+ * should apply it to both.
+ *
+ * @throws NetworkException the request never completed.
+ */
+ public function get(string $url, array $headers, float $timeout): Response;
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4bf7efc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "cepis-woocommerce",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Test harness for CEP Autofill for WooCommerce: drives both WooCommerce checkouts in Chromium against a fake cep.is",
+ "license": "ISC",
+ "author": "Murilo Ijanc <[email protected]>",
+ "type": "module",
+ "scripts": {
+ "test": "bun test test/checkout.test.js"
+ },
+ "devDependencies": {
+ "playwright-core": "^1.62.0",
+ "prettier": "^3.9.6"
+ }
+}
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..8bdc1e5
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,80 @@
+<?xml version="1.0"?>
+<ruleset name="cepis-woocommerce">
+ <description>WordPress coding standards for the plugin's own code.</description>
+
+ <file>.</file>
+
+ <exclude-pattern>/node_modules/*</exclude-pattern>
+ <exclude-pattern>/dist/*</exclude-pattern>
+ <exclude-pattern>/vendor/*</exclude-pattern>
+
+ <!--
+ The bundled client is PSR-12 upstream and is copied in verbatim by
+ tools/update-vendor.sh. Reformatting it here would make every refresh a
+ merge conflict.
+ -->
+ <exclude-pattern>/lib/cepis/*</exclude-pattern>
+
+ <arg name="extensions" value="php"/>
+ <arg name="basepath" value="."/>
+ <arg name="colors"/>
+ <arg value="sp"/>
+
+ <rule ref="WordPress">
+ <exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
+ </rule>
+
+ <config name="minimum_wp_version" value="6.9"/>
+
+ <rule ref="WordPress.WP.I18n">
+ <properties>
+ <property name="text_domain" type="array">
+ <element value="cepis-woocommerce"/>
+ </property>
+ </properties>
+ </rule>
+
+ <rule ref="WordPress.NamingConventions.PrefixAllGlobals">
+ <properties>
+ <property name="prefixes" type="array">
+ <element value="cepis_woo"/>
+ <element value="Cepis_Woo"/>
+ <element value="CEPIS_WOO"/>
+ <element value="cepis_woocommerce"/>
+ </property>
+ </properties>
+ </rule>
+
+ <!--
+ Properties of the bundled client, which is English camelCase by design
+ and is not this plugin's to rename.
+ -->
+ <rule ref="WordPress.NamingConventions.ValidVariableName">
+ <properties>
+ <property name="allowed_custom_properties" type="array">
+ <element value="retryAfter"/>
+ <element value="baseUrl"/>
+ <element value="userAgent"/>
+ </property>
+ </properties>
+ </rule>
+
+ <rule ref="WordPress.WP.Capabilities">
+ <properties>
+ <property name="custom_capabilities" type="array">
+ <element value="manage_woocommerce"/>
+ </property>
+ </properties>
+ </rule>
+
+ <!--
+ Both files delete transients by prefix, and WordPress has no API for
+ that: `delete_transient()` needs the exact key. The query is prepared
+ and the result is fed straight back into `delete_transient()`, so the
+ caching the sniff asks for is the very thing being cleared.
+ -->
+ <rule ref="WordPress.DB.DirectDatabaseQuery">
+ <exclude-pattern>/uninstall.php</exclude-pattern>
+ <exclude-pattern>/includes/class-cepis-woo-lookup.php</exclude-pattern>
+ </rule>
+</ruleset>
diff --git a/test/checkout.test.js b/test/checkout.test.js
new file mode 100644
index 0000000..074b13a
--- /dev/null
+++ b/test/checkout.test.js
@@ -0,0 +1,598 @@
+/**
+ * The plugin driven through a real browser against a real WooCommerce.
+ *
+ * Nothing here is stubbed except the API itself: WordPress, WooCommerce, the
+ * theme, the block checkout and its data store all run for real in the
+ * containers `compose.yml` describes, and Chromium drives them. The one fake is
+ * `fake-api.js` standing in for cep.is, so a test never depends on the network.
+ *
+ * Chromium is told to resolve `fakecep` to the loopback address; the WordPress
+ * container resolves the same name to the host gateway. Both sides therefore
+ * spell the API's address the same way, which is what lets one `base_url`
+ * setting serve the direct and the proxy mode.
+ *
+ * @module
+ */
+
+import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { readdirSync } from "node:fs";
+import { chromium } from "playwright-core";
+import { startFakeApi } from "./fake-api.js";
+import { FAKE_PORT, FAKE_URL, SITE, drainDebugLog, setOption, wp, wpEval } from "./wp.js";
+
+const PRODUCT = wp(["option", "get", "cepis_test_product"]);
+const BLOCKS_PAGE = wp(["option", "get", "cepis_test_blocks_page"]);
+const CLASSIC_PAGE = wp(["option", "get", "cepis_test_classic_page"]);
+
+let browser;
+let api;
+
+/**
+ * Locate the Chromium that Playwright installed.
+ *
+ * @returns {string} Path to the executable.
+ */
+function chromiumPath() {
+ if (process.env.CEPIS_CHROMIUM) {
+ return process.env.CEPIS_CHROMIUM;
+ }
+
+ const root = `${process.env.HOME}/.cache/ms-playwright`;
+ const build = readdirSync(root)
+ .filter((name) => name.startsWith("chromium-"))
+ .sort()
+ .pop();
+
+ return `${root}/${build}/chrome-linux64/chrome`;
+}
+
+beforeAll(async () => {
+ api = startFakeApi(FAKE_PORT);
+ browser = await chromium.launch({
+ executablePath: chromiumPath(),
+ args: ["--no-sandbox", "--host-resolver-rules=MAP fakecep 127.0.0.1"],
+ });
+
+ setOption("cepis_woo_enabled", "yes");
+ setOption("cepis_woo_base_url", FAKE_URL);
+ setOption("cepis_woo_mode", "direct");
+ setOption("cepis_woo_overwrite", "auto");
+ setOption("cepis_woo_focus_number", "yes");
+ setOption("cepis_test_br_fields", "no");
+ drainDebugLog();
+});
+
+afterAll(async () => {
+ setOption("woocommerce_checkout_page_id", BLOCKS_PAGE);
+ await browser?.close();
+ api?.stop();
+});
+
+beforeEach(() => {
+ api.reset();
+});
+
+/**
+ * Open a checkout with one product in the cart, collecting what the page asks
+ * the network for.
+ *
+ * @param {string} path Path of the checkout page.
+ * @returns {Promise<{page: import("playwright-core").Page, requests: Array<{url: string, body: string}>, close: () => Promise<void>}>}
+ */
+async function checkout(path) {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+ const requests = [];
+ const errors = [];
+
+ page.on("request", (request) => {
+ requests.push({ url: request.url(), body: request.postData() || "" });
+ });
+ page.on("pageerror", (error) => errors.push(String(error)));
+
+ await page.goto(`${SITE}/?add-to-cart=${PRODUCT}`, { waitUntil: "domcontentloaded" });
+ await page.goto(`${SITE}${path}`, { waitUntil: "domcontentloaded" });
+
+ return {
+ page,
+ requests,
+ errors,
+ async close() {
+ await context.close();
+ },
+ };
+}
+
+/**
+ * Wait until the Checkout block has hydrated and stopped talking to the Store
+ * API.
+ *
+ * The block seeds its address fields from the cart the server returns, and
+ * typing while that is in flight loses characters — the response resets the
+ * controlled input. That is WooCommerce's own behaviour on a freshly opened
+ * checkout, not something this plugin causes, so the test waits it out.
+ *
+ * @param {import("playwright-core").Page} page Page showing the block checkout.
+ */
+async function settled(page) {
+ await page.waitForSelector("#shipping-postcode");
+ await page.waitForSelector(".wc-block-checkout:not(.is-loading)", { timeout: 30000 });
+ await page.waitForFunction(
+ () => {
+ const store = window.wp && window.wp.data && window.wp.data.select("wc/store/cart");
+ return !!(store && store.getCustomerData && store.getCustomerData().shippingAddress);
+ },
+ undefined,
+ { timeout: 30000 },
+ );
+ await page.waitForLoadState("networkidle");
+ await page.waitForTimeout(500);
+}
+
+/**
+ * Wait until the classic checkout has stopped moving.
+ *
+ * WooCommerce announces "added to your cart" in a banner that takes focus for
+ * screen-reader users a moment after the page loads. Typing before that has
+ * happened means the banner steals focus mid-CEP, which a real shopper never
+ * experiences — they are already looking at the form by then.
+ *
+ * @param {import("playwright-core").Page} page Page showing the classic checkout.
+ */
+async function ready(page) {
+ await page.waitForSelector("#billing_postcode");
+ await page.waitForLoadState("networkidle");
+ await page.waitForTimeout(500);
+}
+
+/**
+ * Type a CEP the way a shopper does.
+ *
+ * The block checkout's inputs are React-controlled and setting `value` in one
+ * go is not reliably seen; real key events are, and they exercise the widget's
+ * debounce as well.
+ *
+ * @param {import("playwright-core").Page} page Page under test.
+ * @param {string} selector CEP field.
+ * @param {string} cep Eight digits.
+ */
+async function typeCep(page, selector, cep) {
+ await page.click(selector);
+ await page.keyboard.press("ControlOrMeta+A");
+ await page.locator(selector).pressSequentially(cep, { delay: 40 });
+}
+
+describe("plugin", () => {
+ test("is active", () => {
+ expect(wp(["plugin", "get", "cepis-woocommerce", "--field=status"])).toBe("active");
+ });
+
+ test("declares the version WooCommerce reports", () => {
+ expect(wp(["plugin", "get", "cepis-woocommerce", "--field=version"])).toBe("0.1.0");
+ });
+});
+
+describe("block checkout", () => {
+ beforeAll(() => {
+ setOption("woocommerce_checkout_page_id", BLOCKS_PAGE);
+ setOption("cepis_woo_mode", "direct");
+ });
+
+ test("fills the address and WooCommerce sees the state change", async () => {
+ const { page, requests, errors, close } = await checkout("/checkout/");
+
+ await settled(page);
+ await typeCep(page, "#shipping-postcode", "01001000");
+
+ await page.waitForFunction(
+ () => document.querySelector("#shipping-address_1").value !== "",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const filled = await page.evaluate(() => ({
+ street: document.querySelector("#shipping-address_1").value,
+ city: document.querySelector("#shipping-city").value,
+ state: document.querySelector("#shipping-state").value,
+ stateTag: document.querySelector("#shipping-state").tagName,
+ store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress,
+ }));
+
+ expect(filled.street).toBe("Praça da Sé");
+ expect(filled.city).toBe("São Paulo");
+ expect(filled.stateTag).toBe("SELECT");
+ expect(filled.state).toBe("SP");
+ expect(filled.store.state).toBe("SP");
+ expect(filled.store.city).toBe("São Paulo");
+
+ await page.waitForFunction(
+ () =>
+ performance
+ .getEntriesByType("resource")
+ .some((entry) => entry.name.includes("/wc/store/v1/")),
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const pushed = requests.filter(
+ (request) => request.url.includes("/wc/store/v1/") && request.body.includes('"state":"SP"'),
+ );
+ expect(pushed.length).toBeGreaterThan(0);
+ expect(pushed.some((request) => request.body.includes("São Paulo"))).toBe(true);
+
+ expect(api.state.requests.filter((entry) => entry.cep === "01001000")).toHaveLength(1);
+ expect(errors).toEqual([]);
+
+ await close();
+ }, 90000);
+
+ test("replaces the state WooCommerce pre-selected", async () => {
+ const { page, close } = await checkout("/checkout/");
+
+ await settled(page);
+ expect(await page.inputValue("#shipping-state")).toBe("SP");
+
+ await typeCep(page, "#shipping-postcode", "20040020");
+ await page.waitForFunction(
+ () => document.querySelector("#shipping-state").value === "RJ",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const filled = await page.evaluate(() => ({
+ street: document.querySelector("#shipping-address_1").value,
+ city: document.querySelector("#shipping-city").value,
+ store: window.wp.data.select("wc/store/cart").getCustomerData().shippingAddress,
+ }));
+
+ expect(filled.street).toBe("Avenida Rio Branco");
+ expect(filled.city).toBe("Rio de Janeiro");
+ expect(filled.store.state).toBe("RJ");
+
+ await close();
+ }, 90000);
+
+ test("a CEP that does not exist shows a message and clears nothing", async () => {
+ const { page, close } = await checkout("/checkout/");
+
+ await settled(page);
+ await typeCep(page, "#shipping-postcode", "01001000");
+ await page.waitForFunction(
+ () => document.querySelector("#shipping-address_1").value !== "",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ await typeCep(page, "#shipping-postcode", "99999999");
+ await page.waitForFunction(() => document.body.innerText.includes("CEP not found"), undefined, {
+ timeout: 15000,
+ });
+
+ const after = await page.evaluate(() => ({
+ street: document.querySelector("#shipping-address_1").value,
+ city: document.querySelector("#shipping-city").value,
+ state: document.querySelector("#shipping-state").value,
+ }));
+
+ expect(after.street).toBe("Praça da Sé");
+ expect(after.city).toBe("São Paulo");
+ expect(after.state).toBe("SP");
+
+ await close();
+ }, 90000);
+});
+
+describe("classic checkout", () => {
+ beforeAll(() => {
+ setOption("woocommerce_checkout_page_id", CLASSIC_PAGE);
+ setOption("cepis_woo_mode", "direct");
+ });
+
+ test("fills the address and WooCommerce recalculates with the new state", async () => {
+ setOption("cepis_test_br_fields", "no");
+ const { page, requests, errors, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ await typeCep(page, "#billing_postcode", "01001000");
+
+ await page.waitForFunction(
+ () => document.querySelector("#billing_address_1").value !== "",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const filled = await page.evaluate(() => ({
+ street: document.querySelector("#billing_address_1").value,
+ city: document.querySelector("#billing_city").value,
+ state: document.querySelector("#billing_state").value,
+ stateTag: document.querySelector("#billing_state").tagName,
+ shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "",
+ status: (document.querySelector(".cepis-autofill-status") || {}).textContent || "",
+ }));
+
+ expect(filled.street).toBe("Praça da Sé");
+ expect(filled.city).toBe("São Paulo");
+ expect(filled.stateTag).toBe("SELECT");
+ expect(filled.state).toBe("SP");
+ expect(filled.shown).toContain("São Paulo");
+ expect(filled.status).toBe("Address found.");
+
+ await page.waitForFunction(
+ () => window.jQuery && window.jQuery("form.checkout").length > 0,
+ undefined,
+ { timeout: 10000 },
+ );
+ await page.waitForTimeout(3000);
+
+ const reviews = requests.filter(
+ (request) =>
+ request.url.includes("wc-ajax=update_order_review") &&
+ decodeURIComponent(request.body).includes("billing_state=SP"),
+ );
+ expect(reviews.length).toBeGreaterThan(0);
+
+ expect(errors).toEqual([]);
+ await close();
+ }, 90000);
+
+ test("fills neighbourhood and moves to the number when the store has those fields", async () => {
+ setOption("cepis_test_br_fields", "yes");
+ const { page, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ await page.waitForSelector("#billing_neighborhood");
+ await typeCep(page, "#billing_postcode", "01001000");
+
+ await page.waitForFunction(
+ () => document.querySelector("#billing_neighborhood").value !== "",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const filled = await page.evaluate(() => ({
+ neighborhood: document.querySelector("#billing_neighborhood").value,
+ number: document.querySelector("#billing_number").value,
+ focused: document.activeElement.id,
+ }));
+
+ expect(filled.neighborhood).toBe("Sé");
+ expect(filled.number).toBe("");
+ expect(filled.focused).toBe("billing_number");
+
+ setOption("cepis_test_br_fields", "no");
+ await close();
+ }, 90000);
+
+ test("replaces the state WooCommerce pre-selected, and works without the Brazilian fields", async () => {
+ setOption("cepis_test_br_fields", "no");
+ const { page, requests, errors, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ expect(await page.inputValue("#billing_state")).toBe("SP");
+
+ await typeCep(page, "#billing_postcode", "20040020");
+
+ await page.waitForFunction(
+ () => document.querySelector("#billing_state").value === "RJ",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const filled = await page.evaluate(() => ({
+ street: document.querySelector("#billing_address_1").value,
+ city: document.querySelector("#billing_city").value,
+ state: document.querySelector("#billing_state").value,
+ shown: (document.querySelector("#select2-billing_state-container") || {}).textContent || "",
+ address2: document.querySelector("#billing_address_2").value,
+ hasNeighborhood: !!document.querySelector("#billing_neighborhood"),
+ hasNumber: !!document.querySelector("#billing_number"),
+ }));
+
+ expect(filled.hasNeighborhood).toBe(false);
+ expect(filled.hasNumber).toBe(false);
+ expect(filled.street).toBe("Avenida Rio Branco");
+ expect(filled.city).toBe("Rio de Janeiro");
+ expect(filled.state).toBe("RJ");
+ expect(filled.shown).toContain("Rio de Janeiro");
+ expect(filled.address2).toBe("");
+
+ await page.waitForTimeout(3000);
+ const reviews = requests.filter(
+ (request) =>
+ request.url.includes("wc-ajax=update_order_review") &&
+ decodeURIComponent(request.body).includes("billing_state=RJ"),
+ );
+ expect(reviews.length).toBeGreaterThan(0);
+
+ expect(errors).toEqual([]);
+
+ await close();
+ }, 90000);
+
+ test("a CEP that does not exist shows a message and clears nothing", async () => {
+ const { page, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ await typeCep(page, "#billing_postcode", "01001000");
+ await page.waitForFunction(
+ () => document.querySelector("#billing_address_1").value !== "",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ await typeCep(page, "#billing_postcode", "99999999");
+ await page.waitForFunction(
+ () =>
+ (document.querySelector(".cepis-autofill-status") || {}).textContent ===
+ "CEP not found. Check the number or fill the address in by hand.",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ const after = await page.evaluate(() => ({
+ street: document.querySelector("#billing_address_1").value,
+ city: document.querySelector("#billing_city").value,
+ state: document.querySelector("#billing_state").value,
+ invalid: document.querySelector("#billing_postcode").getAttribute("aria-invalid"),
+ }));
+
+ expect(after.street).toBe("Praça da Sé");
+ expect(after.city).toBe("São Paulo");
+ expect(after.state).toBe("SP");
+ expect(after.invalid).toBe("true");
+
+ await close();
+ }, 90000);
+});
+
+describe("proxy mode", () => {
+ beforeAll(() => {
+ setOption("woocommerce_checkout_page_id", CLASSIC_PAGE);
+ setOption("cepis_woo_mode", "proxy");
+ wpEval(
+ "global $wpdb; $wpdb->query( \"DELETE FROM {$wpdb->options} WHERE option_name LIKE '%_transient_%cepis_woo_%'\" );",
+ );
+ });
+
+ afterAll(() => {
+ setOption("cepis_woo_mode", "direct");
+ });
+
+ test("the browser talks to this site and PHP talks to the API", async () => {
+ const { page, requests, errors, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ await typeCep(page, "#billing_postcode", "01001000");
+
+ await page.waitForFunction(
+ () => document.querySelector("#billing_address_1").value !== "",
+ undefined,
+ { timeout: 20000 },
+ );
+
+ expect(await page.inputValue("#billing_address_1")).toBe("Praça da Sé");
+ expect(await page.inputValue("#billing_city")).toBe("São Paulo");
+ expect(await page.inputValue("#billing_state")).toBe("SP");
+
+ const proxied = requests.filter((request) =>
+ /\/cepis\/v1\/lookup\/[A-Za-z0-9]+\/01001000/.test(request.url),
+ );
+ expect(proxied.length).toBeGreaterThan(0);
+ expect(requests.some((request) => request.url.includes("fakecep"))).toBe(false);
+
+ const served = api.state.requests.filter((entry) => entry.cep === "01001000");
+ expect(served.length).toBe(1);
+ expect(served[0].userAgent).toContain("cepis-woocommerce/0.1.0");
+
+ expect(errors).toEqual([]);
+ await close();
+ }, 90000);
+
+ test("the answer is cached, so a second shopper costs no request", async () => {
+ api.reset();
+ const first = await checkout("/checkout-classico/");
+ await ready(first.page);
+ await typeCep(first.page, "#billing_postcode", "20040020");
+ await first.page.waitForFunction(
+ () => document.querySelector("#billing_address_1").value !== "",
+ undefined,
+ { timeout: 20000 },
+ );
+ await first.close();
+
+ expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1);
+
+ const second = await checkout("/checkout-classico/");
+ await ready(second.page);
+ await typeCep(second.page, "#billing_postcode", "20040020");
+ await second.page.waitForFunction(
+ () => document.querySelector("#billing_address_1").value !== "",
+ undefined,
+ { timeout: 20000 },
+ );
+ expect(await second.page.inputValue("#billing_city")).toBe("Rio de Janeiro");
+ await second.close();
+
+ expect(api.state.requests.filter((entry) => entry.cep === "20040020").length).toBe(1);
+ }, 120000);
+
+ test("the REST route refuses a request without a valid token", async () => {
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ const withoutToken = await page.evaluate(async (site) => {
+ const response = await fetch(`${site}/wp-json/cepis/v1/lookup/01001000`);
+ return response.status;
+ }, SITE);
+
+ const wrongToken = await page.evaluate(async (site) => {
+ const response = await fetch(`${site}/wp-json/cepis/v1/lookup/deadbeef00/01001000`);
+ return { status: response.status, body: await response.text() };
+ }, SITE);
+
+ expect(withoutToken).toBe(404);
+ expect(wrongToken.status).toBe(403);
+ expect(wrongToken.body).toContain("cepis_invalid_token");
+
+ await context.close();
+ }, 60000);
+});
+
+describe("translations", () => {
+ beforeAll(() => {
+ setOption("woocommerce_checkout_page_id", CLASSIC_PAGE);
+ setOption("cepis_test_locale", "pt_BR");
+ });
+
+ afterAll(() => {
+ setOption("cepis_test_locale", "");
+ });
+
+ test("a store in Portuguese gets the shipped Portuguese messages", async () => {
+ const { page, close } = await checkout("/checkout-classico/");
+
+ await ready(page);
+ await typeCep(page, "#billing_postcode", "99999999");
+
+ await page.waitForFunction(
+ () =>
+ (document.querySelector(".cepis-autofill-status") || {}).textContent ===
+ "CEP não encontrado. Confira o número ou preencha o endereço manualmente.",
+ undefined,
+ { timeout: 15000 },
+ );
+
+ await close();
+ }, 90000);
+});
+
+describe("housekeeping", () => {
+ test("uninstalling removes every option and cached answer", () => {
+ wpEval(
+ 'define( "WP_UNINSTALL_PLUGIN", "cepis-woocommerce/cepis-woocommerce.php" );' +
+ 'include WP_PLUGIN_DIR . "/cepis-woocommerce/uninstall.php";',
+ );
+
+ const left = wpEval(
+ "global $wpdb;" +
+ "echo (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE 'cepis_woo_%' OR option_name LIKE '%transient%cepis_woo_%'\" );",
+ );
+
+ expect(left).toBe("0");
+
+ setOption("cepis_woo_enabled", "yes");
+ setOption("cepis_woo_base_url", FAKE_URL);
+ setOption("cepis_woo_mode", "direct");
+ });
+
+ test("WP_DEBUG logged nothing while all of that happened", () => {
+ const log = drainDebugLog();
+ const ours = log
+ .split("\n")
+ .filter((line) => line.trim() !== "")
+ .filter((line) => !line.includes("/wp-content/plugins/woocommerce/"))
+ .filter((line) => !line.includes("/wp-includes/"));
+
+ expect(ours).toEqual([]);
+ });
+});
diff --git a/test/compose.yml b/test/compose.yml
new file mode 100644
index 0000000..ff0cbdd
--- /dev/null
+++ b/test/compose.yml
@@ -0,0 +1,61 @@
+name: cepis-woocommerce-test
+
+services:
+ db:
+ image: mysql:8
+ environment:
+ MYSQL_ROOT_PASSWORD: wordpress
+ MYSQL_DATABASE: wordpress
+ MYSQL_USER: wordpress
+ MYSQL_PASSWORD: wordpress
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uwordpress", "-pwordpress"]
+ interval: 3s
+ timeout: 5s
+ retries: 30
+
+ wordpress:
+ image: wordpress:latest
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "8090:80"
+ extra_hosts:
+ - "fakecep:host-gateway"
+ environment:
+ WORDPRESS_DB_HOST: db:3306
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: wordpress
+ WORDPRESS_DB_NAME: wordpress
+ WORDPRESS_DEBUG: "1"
+ WORDPRESS_CONFIG_EXTRA: |
+ define( 'WP_DEBUG', true );
+ define( 'WP_DEBUG_LOG', true );
+ define( 'WP_DEBUG_DISPLAY', false );
+ define( 'SCRIPT_DEBUG', true );
+ define( 'DISABLE_WP_CRON', true );
+ volumes:
+ - wp:/var/www/html
+ - ..:/var/www/html/wp-content/plugins/cepis-woocommerce
+
+ cli:
+ image: wordpress:cli
+ depends_on:
+ - wordpress
+ user: "33:33"
+ extra_hosts:
+ - "fakecep:host-gateway"
+ environment:
+ WORDPRESS_DB_HOST: db:3306
+ WORDPRESS_DB_USER: wordpress
+ WORDPRESS_DB_PASSWORD: wordpress
+ WORDPRESS_DB_NAME: wordpress
+ WP_CLI_CACHE_DIR: /tmp/wp-cli-cache
+ volumes:
+ - wp:/var/www/html
+ - ..:/var/www/html/wp-content/plugins/cepis-woocommerce
+ entrypoint: ["tail", "-f", "/dev/null"]
+
+volumes:
+ wp:
diff --git a/test/fake-api.js b/test/fake-api.js
new file mode 100644
index 0000000..4b6ac39
--- /dev/null
+++ b/test/fake-api.js
@@ -0,0 +1,104 @@
+/**
+ * A fake cep.is, so the test suite never touches the network.
+ *
+ * Same routes, bodies and status codes as the real API, plus a request log the
+ * tests assert on. It listens on every interface because two different clients
+ * have to reach it: the browser on the host, and PHP inside the WordPress
+ * container through the `fakecep` host alias.
+ *
+ * @module
+ */
+
+const ADDRESSES = {
+ "01001000": {
+ cep: "01001000",
+ logradouro: "Praça da Sé",
+ complemento: "lado ímpar",
+ unidade: "",
+ bairro: "Sé",
+ localidade: "São Paulo",
+ uf: "SP",
+ estado: "São Paulo",
+ regiao: "Sudeste",
+ ibge: "3550308",
+ gia: "1004",
+ ddd: "11",
+ siafi: "7107",
+ geo: { lat: -23.567202, lon: -46.649659, raio_m: 1084, precisao: "logradouro" },
+ },
+ 20040020: {
+ cep: "20040020",
+ logradouro: "Avenida Rio Branco",
+ complemento: "até 0999 - lado ímpar",
+ unidade: "",
+ bairro: "Centro",
+ localidade: "Rio de Janeiro",
+ uf: "RJ",
+ estado: "Rio de Janeiro",
+ regiao: "Sudeste",
+ ibge: "3304557",
+ gia: "",
+ ddd: "21",
+ siafi: "6001",
+ },
+};
+
+const HEADERS = {
+ "Access-Control-Allow-Origin": "*",
+ "Content-Type": "application/json; charset=utf-8",
+};
+
+/**
+ * Start the fake API.
+ *
+ * `state.requests` collects `{cep, userAgent}` for every lookup, which is how
+ * the proxy tests prove the request came from PHP rather than the browser.
+ *
+ * @param {number} port Port to listen on.
+ * @returns {{port: number, state: {requests: Array<{cep: string, userAgent: string}>}, reset: () => void, stop: () => void}}
+ */
+export function startFakeApi(port) {
+ const state = { requests: [] };
+
+ const server = Bun.serve({
+ port,
+ hostname: "0.0.0.0",
+ fetch(request) {
+ const cep = new URL(request.url).pathname.slice(1);
+ state.requests.push({ cep, userAgent: request.headers.get("user-agent") || "" });
+
+ if (!/^\d{8}$/.test(cep)) {
+ return new Response(
+ JSON.stringify({ erro: "cep_invalido", mensagem: "CEP deve ter 8 dígitos" }),
+ { status: 400, headers: HEADERS },
+ );
+ }
+
+ const address = ADDRESSES[cep];
+ if (!address) {
+ return new Response(
+ JSON.stringify({ erro: "cep_nao_encontrado", mensagem: "CEP não existe na base do DNE" }),
+ { status: 404, headers: HEADERS },
+ );
+ }
+
+ return new Response(JSON.stringify(address), { status: 200, headers: HEADERS });
+ },
+ });
+
+ return {
+ port: server.port,
+ state,
+ reset() {
+ state.requests.length = 0;
+ },
+ stop() {
+ server.stop(true);
+ },
+ };
+}
+
+if (import.meta.main) {
+ const api = startFakeApi(Number(process.argv[2] || 8899));
+ console.log(`fake cep.is on :${api.port}`);
+}
diff --git a/test/setup.sh b/test/setup.sh
new file mode 100755
index 0000000..6925386
--- /dev/null
+++ b/test/setup.sh
@@ -0,0 +1,158 @@
+#!/bin/sh
+#
+# Stand up the WordPress + WooCommerce store the browser tests drive.
+#
+# Idempotent: running it again on a live stack reconfigures rather than
+# rebuilds. `make clean` is what throws the volumes away.
+
+set -eu
+
+cd "$(dirname "$0")"
+
+SITE="http://127.0.0.1:8090"
+
+wp() {
+ docker compose exec -T cli wp "$@"
+}
+
+printf 'bringing the stack up\n'
+docker compose up -d >/dev/null
+
+printf 'waiting for the web server\n'
+i=0
+while [ "$i" -lt 90 ]; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$SITE/" 2>/dev/null || echo 000)
+ [ "$code" != "000" ] && break
+ i=$((i + 1))
+ sleep 2
+done
+[ "$code" = "000" ] && { printf 'web server never answered\n' >&2; exit 1; }
+
+if ! wp core is-installed >/dev/null 2>&1; then
+ printf 'installing WordPress\n'
+ wp core install \
+ --url="$SITE" \
+ --title="CEP Test Store" \
+ --admin_user=admin \
+ --admin_password=admin \
+ --skip-email >/dev/null
+fi
+
+wp rewrite structure '/%postname%/' >/dev/null 2>&1
+wp rewrite flush >/dev/null 2>&1
+
+if ! wp plugin is-installed woocommerce >/dev/null 2>&1; then
+ printf 'installing WooCommerce\n'
+ wp plugin install woocommerce >/dev/null
+fi
+wp plugin activate woocommerce >/dev/null
+
+printf 'configuring the store\n'
+wp option update woocommerce_store_address "Praça da Sé" >/dev/null
+wp option update woocommerce_store_city "São Paulo" >/dev/null
+wp option update woocommerce_default_country "BR:SP" >/dev/null
+wp option update woocommerce_store_postcode "01001000" >/dev/null
+wp option update woocommerce_currency BRL >/dev/null
+wp option update woocommerce_allowed_countries all >/dev/null
+wp option update woocommerce_enable_guest_checkout yes >/dev/null
+wp option update woocommerce_enable_checkout_login_reminder no >/dev/null
+wp option update woocommerce_calc_taxes no >/dev/null
+wp option update woocommerce_cod_settings \
+ '{"enabled":"yes","title":"Pagamento na entrega","description":"","instructions":""}' \
+ --format=json >/dev/null
+wp eval 'if ( ! WC_Shipping_Zones::get_zone( 0 )->get_shipping_methods() ) { WC_Shipping_Zones::get_zone( 0 )->add_shipping_method( "flat_rate" ); }' >/dev/null
+
+wp eval '
+$id = (int) get_option( "cepis_test_product", 0 );
+if ( ! $id || ! wc_get_product( $id ) ) {
+ $product = new WC_Product_Simple();
+ $product->set_name( "Caneca" );
+ $product->set_regular_price( "25" );
+ $product->set_virtual( false );
+ $id = $product->save();
+ update_option( "cepis_test_product", $id );
+}
+' >/dev/null
+
+wp eval '
+$blocks = 0;
+foreach ( get_pages( array( "post_status" => "publish" ) ) as $page ) {
+ if ( has_block( "woocommerce/checkout", $page ) ) {
+ $blocks = (int) $page->ID;
+ break;
+ }
+}
+if ( ! $blocks ) {
+ WP_CLI::error( "no page holds the woocommerce/checkout block" );
+}
+update_option( "cepis_test_blocks_page", $blocks );
+update_option( "woocommerce_checkout_page_id", $blocks );
+
+$classic = get_page_by_path( "checkout-classico" );
+if ( ! $classic ) {
+ $id = wp_insert_post( array(
+ "post_title" => "Checkout classico",
+ "post_name" => "checkout-classico",
+ "post_content" => "[woocommerce_checkout]",
+ "post_status" => "publish",
+ "post_type" => "page",
+ ) );
+} else {
+ $id = (int) $classic->ID;
+}
+update_option( "cepis_test_classic_page", $id );
+' >/dev/null
+
+printf 'installing the fixture that imitates Brazilian Market on WooCommerce\n'
+docker compose exec -T cli sh -c 'mkdir -p /var/www/html/wp-content/mu-plugins && cat > /var/www/html/wp-content/mu-plugins/cepis-test-br-fields.php' <<'PHP'
+<?php
+/**
+ * Adds the house-number and neighbourhood fields that "Brazilian Market on
+ * WooCommerce" adds, so the plugin can be tested with and without them.
+ * Switched by the `cepis_test_br_fields` option.
+ */
+
+function cepis_test_br_fields( $fields, $prefix ) {
+ if ( 'yes' !== get_option( 'cepis_test_br_fields' ) ) {
+ return $fields;
+ }
+
+ $fields[ $prefix . '_number' ] = array(
+ 'label' => 'Número',
+ 'required' => false,
+ 'class' => array( 'form-row-first', 'address-field' ),
+ 'priority' => 51,
+ );
+ $fields[ $prefix . '_neighborhood' ] = array(
+ 'label' => 'Bairro',
+ 'required' => false,
+ 'class' => array( 'form-row-last', 'address-field' ),
+ 'priority' => 52,
+ );
+
+ return $fields;
+}
+
+add_filter( 'woocommerce_billing_fields', function ( $fields ) {
+ return cepis_test_br_fields( $fields, 'billing' );
+} );
+
+add_filter( 'woocommerce_shipping_fields', function ( $fields ) {
+ return cepis_test_br_fields( $fields, 'shipping' );
+} );
+
+/**
+ * Force the site locale from an option, so the shipped translation can be
+ * exercised without downloading WordPress's own language pack.
+ */
+add_filter( 'locale', function ( $locale ) {
+ $forced = get_option( 'cepis_test_locale' );
+
+ return $forced ? $forced : $locale;
+} );
+PHP
+
+wp plugin activate cepis-woocommerce >/dev/null
+
+printf 'ready: %s\n' "$SITE"
diff --git a/test/wp.js b/test/wp.js
new file mode 100644
index 0000000..ab2d2a7
--- /dev/null
+++ b/test/wp.js
@@ -0,0 +1,88 @@
+/**
+ * Talking to the WordPress under test.
+ *
+ * Everything goes through WP-CLI in the `cli` container of `compose.yml`, so
+ * the tests configure the store exactly the way a store owner would.
+ *
+ * @module
+ */
+
+import { spawnSync } from "node:child_process";
+import { dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+
+/** Where the site answers, from the host. */
+export const SITE = "http://127.0.0.1:8090";
+
+/** Hostname the fake API is reached by, from both the browser and PHP. */
+export const FAKE_HOST = "fakecep";
+
+/** Port the fake API listens on. */
+export const FAKE_PORT = 8899;
+
+/** Base URL of the fake API, as both sides must spell it. */
+export const FAKE_URL = `http://${FAKE_HOST}:${FAKE_PORT}`;
+
+/**
+ * Run a WP-CLI command in the container.
+ *
+ * @param {string[]} args Arguments after `wp`.
+ * @returns {string} Trimmed stdout.
+ */
+export function wp(args) {
+ const result = spawnSync("docker", ["compose", "exec", "-T", "cli", "wp", ...args], {
+ cwd: HERE,
+ encoding: "utf8",
+ });
+
+ if (result.status !== 0) {
+ throw new Error(`wp ${args.join(" ")} failed:\n${result.stdout}\n${result.stderr}`);
+ }
+
+ return String(result.stdout).trim();
+}
+
+/**
+ * Run PHP inside WordPress.
+ *
+ * @param {string} code PHP to evaluate.
+ * @returns {string} Trimmed stdout.
+ */
+export function wpEval(code) {
+ return wp(["eval", code]);
+}
+
+/**
+ * Set one option.
+ *
+ * @param {string} name Option name.
+ * @param {string} value Option value.
+ */
+export function setOption(name, value) {
+ wp(["option", "update", name, value]);
+}
+
+/**
+ * Read the WordPress debug log and clear it.
+ *
+ * @returns {string} Whatever had been logged.
+ */
+export function drainDebugLog() {
+ const result = spawnSync(
+ "docker",
+ [
+ "compose",
+ "exec",
+ "-T",
+ "cli",
+ "sh",
+ "-c",
+ "cat /var/www/html/wp-content/debug.log 2>/dev/null; : > /var/www/html/wp-content/debug.log 2>/dev/null || true",
+ ],
+ { cwd: HERE, encoding: "utf8" },
+ );
+
+ return String(result.stdout || "").trim();
+}
diff --git a/tools/install-phpcs.sh b/tools/install-phpcs.sh
new file mode 100755
index 0000000..63ae7b2
--- /dev/null
+++ b/tools/install-phpcs.sh
@@ -0,0 +1,24 @@
+#!/bin/sh
+#
+# Fetch PHP_CodeSniffer and the WordPress standard into .phpcs/.
+#
+# Not a dependency of the plugin: nothing here ships, and a bare checkout lints
+# with `php -l` alone. Run this once to get the full WordPress review that
+# `make lint` will then include.
+
+set -eu
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+target="$root/.phpcs"
+
+mkdir -p "$target"
+
+docker run --rm -v "$target":/app -w /app composer:2 sh -c '
+ composer config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true &&
+ composer require --dev --no-interaction --quiet \
+ squizlabs/php_codesniffer \
+ wp-coding-standards/wpcs \
+ dealerdirect/phpcodesniffer-composer-installer
+'
+
+printf 'phpcs installed in .phpcs/ — `make lint` will use it\n'
diff --git a/tools/update-vendor.sh b/tools/update-vendor.sh
new file mode 100755
index 0000000..33dc824
--- /dev/null
+++ b/tools/update-vendor.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+#
+# Refresh the two bundled dependencies from their own repositories.
+#
+# Neither is installed at runtime: a WordPress site is deployed by copying
+# files and never runs `composer install` or `bun install`, so both live in
+# this repository and are updated deliberately, by running this.
+#
+# Usage: tools/update-vendor.sh [path-to-cepis-php] [path-to-cepis-autofill]
+
+set -eu
+
+root=$(cd "$(dirname "$0")/.." && pwd)
+php_src=${1:-../cepis-php}
+js_src=${2:-../cepis-autofill}
+
+[ -d "$php_src/src" ] || { printf 'no PHP client at %s\n' "$php_src" >&2; exit 1; }
+[ -d "$js_src" ] || { printf 'no widget at %s\n' "$js_src" >&2; exit 1; }
+
+printf 'refreshing lib/cepis from %s\n' "$php_src"
+rm -f "$root"/lib/cepis/*.php
+cp "$php_src"/src/*.php "$root/lib/cepis/"
+
+printf 'building the widget in %s\n' "$js_src"
+( cd "$js_src" && make build >/dev/null )
+
+printf 'refreshing assets/js/cepis-autofill.min.js\n'
+cp "$js_src/dist/cepis-autofill.min.js" "$root/assets/js/cepis-autofill.min.js"
+
+printf 'done — review the diff and bump CEPIS_WOO_VERSION if anything changed\n'
diff --git a/uninstall.php b/uninstall.php
new file mode 100644
index 0000000..a3c0333
--- /dev/null
+++ b/uninstall.php
@@ -0,0 +1,41 @@
+<?php
+/**
+ * Remove every trace of the plugin when it is deleted.
+ *
+ * Runs on delete, not on deactivate: a store owner switching the plugin off to
+ * debug something should not lose their settings.
+ *
+ * @package CepisWooCommerce
+ */
+
+declare(strict_types=1);
+
+defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
+
+$cepis_woo_options = array(
+ 'cepis_woo_enabled',
+ 'cepis_woo_mode',
+ 'cepis_woo_overwrite',
+ 'cepis_woo_focus_number',
+ 'cepis_woo_base_url',
+);
+
+foreach ( $cepis_woo_options as $cepis_woo_option ) {
+ delete_option( $cepis_woo_option );
+}
+
+global $wpdb;
+
+$cepis_woo_names = $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
+ $wpdb->esc_like( '_transient_cepis_woo_' ) . '%',
+ $wpdb->esc_like( '_transient_timeout_cepis_woo_' ) . '%'
+ )
+);
+
+foreach ( (array) $cepis_woo_names as $cepis_woo_name ) {
+ delete_option( (string) $cepis_woo_name );
+}
+
+wp_cache_flush();