diff --git a/.claude/skills/ipag-sdk-php-execution/SKILL.md b/.claude/skills/ipag-sdk-php-execution/SKILL.md new file mode 100644 index 0000000..b821d37 --- /dev/null +++ b/.claude/skills/ipag-sdk-php-execution/SKILL.md @@ -0,0 +1,394 @@ +--- +name: ipag-sdk-php-execution +description: > + Use este skill sempre que uma task iPag for executada neste projeto + (ipag-sdk-php, o SDK PHP público da API iPag): bug fix em model ou endpoint, + campo ou endpoint novo, compatibilidade de versão de PHP, release do SDK. + Cobre o ciclo completo, do pickup no Jira até a tag publicada. Inclui as + particularidades deste repo: ele é público no GitHub (nada interno em + branch, commit, PR, teste ou changelog), o host não tem PHP (tudo via + Docker) e a correção só chega ao consumidor depois de tag e de atualização + da dependência do lado dele. + Triggers: "pegar essa task", "bora começar", "vamos implementar", + "executar a IPAG-XXXX", "corrigir esse bug no SDK", "adicionar campo no + model", "adicionar endpoint", "publicar versão do SDK", "abrir PR", + "finalizar a task". +--- + +# Skill — Execução de tasks no ipag-sdk-php + +Equivalente, para **este** projeto, do skill genérico de execução iPag. É +autossuficiente e não depende de outro skill estar carregado. + +Contexto técnico não se repete aqui: está em `CLAUDE.md`, `docs/setup.md` e +`docs/sdk.md` (caminhos relativos à raiz do repo). + +--- + +## O que define tudo: repo público e entrega por tag + +| | | +|---|---| +| Host | **GitHub** `ipagdevs/ipag-sdk-php`, **público** | +| Branch principal | `master` (**nunca** `develop`) | +| PR | GitHub. O `gh` **não está instalado** neste host e não há MCP do GitHub, então devolva o link. | +| Distribuição | Packagist, a partir das **tags**. O `composer.json` não tem `version`. | +| Consumidores | integrações de lojistas via Composer e o módulo Magento 2 (`ipagdevs/magento2`), que fixa a versão exata | + +Duas consequências valem para todos os passos: + +1. **Nada interno vai para o repo.** Número de task Jira, link do Jira, nome + ou ID de estabelecimento, CPF/CNPJ ou nome de cliente final e trecho de log + de produção não entram em branch, commit, PR, issue, teste, exemplo ou + `CHANGELOG.md`. O detalhe interno fica no Jira, que é privado. +2. **Merge não é entrega.** A correção só existe para o consumidor depois da + tag (Passo 8), e só chega a ele quando ele atualiza a dependência. + +--- + +## Passo 1 — Pegar a task no Jira + +Via MCP Atlassian, antes de tocar em código: + +1. **Status em andamento.** Se estiver em "To Do", transicione + (`getTransitionsForJiraIssue` → `transitionJiraIssue`). +2. **Data de início.** Preencha com a data atual se estiver vazia. +3. **Story points.** Estime e preencha se não houver. A escala é **Fibonacci: + 1, 3, 5, 8, 13**, nunca 2. + + Para os dois campos, confirme o field id via `getJiraIssue` + (`fields: ["*all"]`) antes de gravar. Não assuma o id. +4. **Leia as issues vinculadas.** Task de bug costuma nascer de um caso de + suporte em outro projeto, e é lá que estão a reprodução, os logs e quem + precisa ser avisado no fim (Passo 8). + +**Se o MCP Atlassian estiver fora do ar**, peça ao usuário o export da task (e +das vinculadas) num arquivo e siga com ele. O que seria gravado no Jira +(status, story points, comentários) vira texto pronto, que você devolve ao +usuário ou grava quando o MCP voltar. Não trave a execução por isso. + +Task tipo `[Review]`: ver "Casos especiais", sem seguir os passos 2 a 8. + +--- + +## Passo 2 — Classificar a task + +| Tipo de task | Onde mexe | Release | +|---|---|---| +| Bug em model ou mutator | `src/Model/**`, `tests/Model/**` | PATCH | +| Bug em endpoint ou HTTP | `src/Endpoint/**`, `src/Core/**`, `src/Http/**`, `tests/Endpoint/**` | PATCH | +| Compatibilidade de PHP | onde o CI acusar | PATCH | +| Campo novo em model | model, teste, exemplo, `README.md` | MINOR | +| Endpoint ou método público novo | endpoint, models, `IpagClient`, testes, exemplo, `README.md` | MINOR | +| Quebra de compatibilidade | **alinhe com o usuário antes** | MAJOR | +| Só docs ou setup de agente | `CLAUDE.md`, `docs/`, `.claude/` | nenhuma | + +Uma correção de bug que **muda o valor serializado** (ex.: o documento deixa +de ser completado com zeros) continua sendo PATCH, mas registre no +`CHANGELOG.md` o que muda para quem dependia do comportamento antigo. + +Confirme o estado antes de começar: + +```bash +git status -sb +git fetch origin && git log --oneline -1 origin/master +# o vendor/ bate com o lock? (só lê; "0 installs, 0 updates" = em dia) +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app:ro -w /app composer:2 install --dry-run --no-interaction +``` + +Se o `vendor/` estiver atrás do lock, ou com dono `root`, reinstale antes de +confiar em qualquer resultado de teste. + +--- + +## Passo 3 — Criar a branch + +```bash +git checkout master && git pull origin master +git checkout -b {tipo}/IIP-{issue}-{descricaoEmCamelCase} +``` + +**O `{issue}` não é o número da task Jira.** O repo é público, e o número +interno não vai para ele. Por isso o prefixo é `IIP-{n}` ("**I**pag +**I**mprovement **P**urpose"), onde `{n}` é o número de uma **issue pública +no GitHub** (`ipagdevs/ipag-sdk-php/issues/{n}`) que descreve o propósito. +É a mesma convenção do módulo Magento 2, adotada em 2026-09 pelo mesmo motivo. + +Prefixos: `fix/` e `feat/` para código, e `docs/` ou `chore/` para o que não +muda o pacote. O histórico tem `feature/IPAG-6787-...` e +`feature/minorFixesPR8`, que são anteriores à convenção e **não** devem ser +seguidos. + +Sem `gh` nem MCP do GitHub, **o usuário cria a issue manualmente**: + +1. Gere título e corpo em Markdown, **com conteúdo público**: o comportamento + do SDK, um exemplo com dado sintético e o esperado contra o obtido. Sem + cliente, sem estabelecimento, sem log de produção e sem link do Jira. +2. Devolva o texto, ou a URL já preenchida: + `https://github.com/ipagdevs/ipag-sdk-php/issues/new?title=...&body=...` + (URL-encoded). +3. Peça o número de volta e só então crie a branch (ou renomeie, com + `git branch -m`, a que já estiver em uso). + +### Consolidação por propósito + +Quando várias tasks Jira formam um propósito único, use **uma** branch e +**uma** issue para o propósito, acumulando os commits de todas. Antes do PR, +squashe num commit só (o GitHub deste repo faz merge commit, não squash, então +o que estiver no PR entra na `master` como está): + +```bash +git checkout master && git pull origin master +git checkout -b {tipo}/IIP-{n}-{proposito} +git merge --squash +git commit -m "..." +git diff origin/master...HEAD --stat # só o que é deste propósito? +``` + +Propósitos diferentes vão em PRs diferentes, mesmo que nasçam da mesma task. +Por exemplo, setup de agente e correção de bug: o PR de release deve conter só +o que entra no release. + +--- + +## Passo 4 — Planejar antes de codar + +Para tasks não triviais, alinhe a abordagem com o usuário antes de escrever +código. Depois, **antes de executar**, poste o plano aprovado como comentário +na task Jira (`addCommentToJiraIssue`): causa raiz, abordagem, bump previsto +(PATCH/MINOR/MAJOR) e como verificar. O comentário é privado e pode ter os +detalhes do caso real. + +Se a task pede "revisar se outros lugares têm a mesma lógica", faça o +levantamento **no plano**, com a lista de arquivos, e não descubra no meio da +implementação. + +--- + +## Passo 5 — Implementar + +- **Sintaxe de PHP 7.4.** A lista do que não pode está na regra 1 do + `CLAUDE.md`. +- **Não rode `composer update` nem `composer require`** sem que a task peça uma + mudança de dependência. Se o lock mudar só no `plugin-api-version`, reverta. +- **A semântica do campo vem de [developers.ipag.com.br](https://developers.ipag.com.br)** + (tipo, formato, máscara, default), não do SDK. +- **Siga o padrão do código vizinho:** os mutators, os accessors tipados, os + models v2 que filtram nulos e o ID na query. Como adicionar endpoint ou + campo está em [sdk.md](../../../docs/sdk.md#adicionar-um-endpoint). +- **Arquivos novos com LF.** Não normalize CRLF de arquivos existentes na + mesma PR. +- **O `README.md` acompanha a API pública:** método, endpoint ou campo novo + ganha uma seção no mesmo PR. +- **Commits:** semânticos, em PT-BR e no presente (`fix: corrige ...`, + `feat: adiciona ...`, `docs:`, `chore:`, `test:`). Corpo em bullets quando + houver mais de uma mudança. **Sem** número de task Jira e **sem** o trailer + `Co-Authored-By: Claude ...`, que o time não usa. + +--- + +## Passo 6 — Testar + +Este repo **tem** suíte real (PHPUnit 9.6, sem rede) e CI em PHP 7.4, 8.3 e +8.4. A tarefa só está pronta com as três versões verdes **localmente**: + +```bash +for v in 7.4 8.3 8.4; do + docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:$v-cli vendor/bin/phpunit || break +done +``` + +Arquivo ou teste único: `... vendor/bin/phpunit tests/Model/CustomerTest.php` +ou `--filter `. Os outros comandos estão em +[setup.md](../../../docs/setup.md#comandos). + +**Em bug, reproduza antes de corrigir.** + +1. Escreva o teste que falha, com dado sintético que tenha a mesma + característica do caso real. +2. Veja-o falhar e só então corrija. + +Para uma reprodução rápida fora da suíte, use um script avulso montado +read-only, que não suja o repo: + +```bash +docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app:ro -v :/s:ro \ + php:7.4-cli php /s/repro.php /app # o script faz require "$argv[1]/vendor/autoload.php" +``` + +O que os testes precisam cobrir: + +- **Model:** construtor, setter, getter e `jsonSerialize()`, com o caso feliz, + os casos de borda pedidos pela task e o erro de validação + (`MutatorAttributeException`/`\TypeError`). +- **Endpoint:** se a mudança toca rota, verbo, query ou body, verifique a + **request** com o middleware de histórico do Guzzle + ([sdk.md § Testes](../../../docs/sdk.md#testes)). Os testes existentes não + fazem isso, e uma rota errada passaria no CI. +- **Dados:** CPF/CNPJ sintéticos válidos, gerados pelo dígito verificador. + Nunca o documento do caso real. + +**Validação contra a API** (quando a task espera comportamento da API, ex. +"a API retorna o documento com a máscara X"): + +1. Rode um exemplo de `examples/` contra a **sandbox**, com credenciais + editadas localmente em `examples/config.php`. +2. Desfaça a edição antes de qualquer `git add` (`git diff --quiet examples/config.php`). +3. Sem credenciais de sandbox, diga isso explicitamente e deixe a validação + pendente. Não a dê como feita com base só no teste unitário. + +Registre o resultado como comentário na task Jira **quando houver consenso** +de que a implementação está finalizada, não no meio de uma iteração. + +--- + +## Passo 7 — Abrir PR + +```bash +git diff origin/master...HEAD --stat # escopo: só este propósito +git diff --quiet examples/config.php # sem credencial +git push -u origin # imprime o link .../pull/new/ +``` + +- **Base: `master`.** O PR roda o CI nas três versões. +- **Devolva ao usuário** o link de criação que o `git push` imprime, com + sugestão de título e descrição. Não dá para criar via CLI ou MCP. +- **Descrição pública:** + - o que muda e por quê, com exemplo sintético; + - como foi testado; + - bump previsto; + - `Relates to #{n}`, com a issue pública. Use `Closes #{n}` só se o PR + encerra o propósito inteiro. + + Sem link do Jira e sem dado do caso real. +- **Não abra o PR por conta própria:** espere o usuário sinalizar que o + propósito está pronto. +- **O `CHANGELOG.md` vai no próprio PR** (Passo 8, item 1). Assim o review + cobre o texto do release. + +--- + +## Passo 8 — Publicar a versão + +Toda task de código (`fix/`, `feat/`) termina em release, a não ser que o +usuário decida acumular mudanças para um release conjunto. O processo completo +está em [setup.md § Versionamento e release](../../../docs/setup.md#versionamento-e-release). +O resumo: + +1. **No PR**, nova entrada no topo do `CHANGELOG.md`: + `## vX.Y.Z - AAAA-MM-DD`, com bullets em PT-BR escritos para o + **consumidor** (o que muda no uso, não o que mudou no código). +2. **Depois do merge**, crie uma tag **anotada** no merge commit da `master`. + **O push da tag publica para todos os consumidores, então só faça com o ok + explícito do usuário:** + + ```bash + git checkout master && git pull origin master + git log -1 --oneline # é o merge commit do PR? + git tag -a vX.Y.Z -m "Release vX.Y.Z" + git push origin vX.Y.Z + ``` + + Não repita os desvios do histórico (tag sem `v`, lightweight, fora de merge + commit). Tag publicada não se move. +3. **GitHub Release:** devolva ao usuário o link + `https://github.com/ipagdevs/ipag-sdk-php/releases/new?tag=vX.Y.Z` com o + texto da entrada do `CHANGELOG.md`. +4. **Packagist:** confira se a versão apareceu em + `https://packagist.org/packages/ipagdevs/ipag-sdk-php`. Se não aparecer, + avise o usuário. Atualizar lá exige acesso de mantenedor. +5. **Avise quem depende:** + - Se a task nasceu de um caso de suporte, prepare o aviso: versão, o que + corrige e o comando de atualização + (`composer require ipagdevs/ipag-sdk-php:^X.Y.Z`). Poste-o como + comentário na issue de origem, após confirmar com o usuário. + - Se a correção importa para o módulo Magento 2, registre que ele fixa a + versão exata e precisa de PR próprio no repo dele (ver "Casos + especiais"). + +--- + +## Padrão de comentários no Jira + +Use `addCommentToJiraIssue` em **Markdown** (`##`, `-`, `` `código` ``, +`**negrito**`), com `contentFormat: "markdown"`. Nunca misture wiki markup +(`h2.`, `{{...}}`, `*bold*`) com `contentFormat: markdown`, porque sai +literal. Para corrigir um comentário já postado, reenvie com o mesmo +`commentId`. + +Comentário de auditoria prematuro (postado antes do consenso) se **apaga** e +dá lugar ao final. Não empilhe um novo "que supera o anterior". + +--- + +## Casos especiais + +### Task só de docs ou setup de agente +Não gera release nem entrada no `CHANGELOG.md`. Um PR com prefixo `docs/` ou +`chore/`. Esses arquivos ficam fora do pacote publicado pelo `export-ignore` +do `.gitattributes`. Se criar outro arquivo que não deve ir para o Packagist, +inclua-o lá. + +### Atualizar o consumidor (módulo Magento 2) +O módulo fixa a versão exata do SDK, então a correção não chega sozinha. +Atualizá-lo é **outra task, em outro repo**, com o skill de execução do +ambiente Magento. Ao propor isso, liste o que muda entre a versão que ele fixa +e a nova (`git log --oneline ..vX.Y.Z`), porque o salto pode trazer +releases intermediários. + +### Task tipo Review +O entregável é um único comentário de review na própria task, sem alterar +código. Diffe contra o merge-base, nunca direto contra a `master`: + +```bash +git fetch origin +base=$(git merge-base origin/master origin/) +git diff $base..origin/ +``` + +Rode a suíte nas três versões na branch revisada. Poste o review em Markdown, +estruturado por severidade: ✅ Verificado OK / 🔴 Pontos a resolver / +🟡 Arquitetura (não bloqueante) / ⚪ Nits. Confira também se a branch respeita +"nada interno no repo". + +### Código vindo da `develop` +A `develop` diverge da `master` e tem accessors que colidem com ela. Se algo +de lá for pedido, traga por cherry-pick para uma branch nova a partir da +`master`, resolvendo a colisão de nomes no plano. Nunca faça merge da +`develop`. + +--- + +## Manter este skill vivo + +Aprendizado novo da execução (comando, permissão, padrão de dados) entra **na +mesma PR da task**. Vai aqui se for sobre *como executar tasks*, e no doc +correspondente em `docs/` se for sobre *como o SDK funciona*. Escreva em termos públicos: o repo é aberto. + +--- + +## Checklist antes de considerar a task pronta + +- [ ] A task está "In Progress", com data de início e story points desde o começo (ou o texto ficou pendente para o usuário, se o MCP estiver fora)? +- [ ] As issues vinculadas foram lidas (reprodução e quem avisar no fim)? +- [ ] O `vendor/` foi conferido contra o lock antes de confiar nos testes? +- [ ] A branch saiu da `master` e segue `{tipo}/IIP-{issue}-{desc}`, com issue pública criada? +- [ ] Se a task é uma entre várias do mesmo propósito, os commits estão acumulando na branch única do propósito? +- [ ] O plano foi postado no Jira antes de codar (tasks não triviais), com a lista de lugares afetados? +- [ ] O bug foi reproduzido por um teste que falhava antes da correção? +- [ ] O código é PHP 7.4 e a suíte está verde em 7.4, 8.3 e 8.4 localmente? +- [ ] Se mexeu em rota, verbo, query ou body, há um teste que inspeciona a request? +- [ ] Os dados de teste e exemplo são sintéticos, sem nada do caso real? +- [ ] O `composer.lock` está intocado, ou a mudança nele é intencional? +- [ ] Os commits estão sem número Jira e sem o trailer `Co-Authored-By: Claude ...`? +- [ ] O `examples/config.php` está sem credencial? +- [ ] O `README.md` foi atualizado, se a API pública mudou? +- [ ] A entrada do `CHANGELOG.md` está no PR, com o bump certo (PATCH/MINOR/MAJOR)? +- [ ] Antes do PR, o histórico foi squashado e o escopo conferido contra `origin/master`? +- [ ] O PR referencia a issue pública (`Relates to #{n}`) e não tem nada interno? +- [ ] A validação contra a API foi feita na sandbox, ou declarada como pendente, e registrada no Jira após o consenso? +- [ ] Depois do merge, com o ok do usuário, a tag anotada foi criada no merge commit, com GitHub Release e Packagist conferidos? +- [ ] Quem depende foi avisado (caso de suporte; módulo Magento, se for o caso)? +- [ ] Os links de PR, issue e release foram devolvidos ao usuário? +- [ ] O aprendizado novo da execução foi registrado (aqui ou no doc correspondente em `docs/`)? diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e333f3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Arquivos de desenvolvimento e de agente: ficam no repo, fora do pacote publicado +/.claude export-ignore +/CLAUDE.md export-ignore +/docs export-ignore +/.gitattributes export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38ebb2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +Todas as mudanças notáveis neste projeto serão documentadas neste arquivo. + +O formato é baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), e este projeto adere ao [Versionamento Semântico](https://semver.org/spec/v2.0.0.html). + +As versões até `v1.4.1` não têm entrada aqui: consulte as [tags](https://github.com/ipagdevs/ipag-sdk-php/tags) e o histórico de commits. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bb7de26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +SDK PHP da API iPag, publicado no Packagist como +[`ipagdevs/ipag-sdk-php`](https://github.com/ipagdevs/ipag-sdk-php). É uma +biblioteca, sem aplicação para subir: o trabalho aqui é editar `src/`, testar +com PHPUnit e publicar uma tag. O `README.md` é a documentação **do +consumidor** e não descreve como o SDK funciona por dentro. Isso fica em +`docs/`. + +**O repositório é público.** Tudo o que for commitado (código, testes, +exemplos, mensagens, PRs e estes docs) fica visível para qualquer pessoa. +Número de task interna, dado de estabelecimento e documento ou log de +produção não entram aqui. + +## Layout + +| Caminho | O que é | +|---|---| +| `src/Core/` | `IpagClient` (ponto de entrada e accessors dos endpoints), `Client`, `Endpoint` (base dos recursos), `IpagEnvironment`, `Enums/` (classes com constantes, não enums nativos) | +| `src/Endpoint/` | Uma classe por recurso da API (`$location` define a rota) | +| `src/Model/` | Payloads de **request**, construídos sobre a DSL de `src/Model/Schema/` | +| `src/Http/` | `GuzzleHttpClient` e `Response`, que é o que os endpoints retornam | +| `src/Exception/` | `HttpClientException` (4xx), `HttpServerException` (5xx), `HttpTransferException` (rede), `ParseException` | +| `src/Support/` | Credenciais e providers de antifraude e adquirente (também são `Model`) | +| `src/Mock/` | `IpagClientMock`, usado pelos testes. Vai no pacote publicado. | +| `tests/` | PHPUnit 9.6, sem rede (Guzzle `MockHandler`) | +| `examples/` | Scripts que fazem chamadas **reais** à sandbox (`examples/config.php`) | +| `CHANGELOG.md` | Uma entrada por release, a partir da primeira versão depois de `v1.4.1` | +| `docs/`, `.claude/skills/`, `CLAUDE.md` | Docs para agentes e devs. Ficam fora do pacote publicado (`export-ignore` no `.gitattributes`). | + +## Regras que economizam tempo + +1. **O piso é o PHP 7.4.** O `composer.json` fixa `config.platform.php=7.4.33` + e o CI roda 7.4, 8.3 e 8.4. Não use sintaxe de PHP 8.0 ou mais nova (union + types, `mixed`, `match`, `?->`, named args, promoção no construtor, `enum`, + `readonly`), nem rode `composer update` sem o pin. +2. **O host não tem PHP nem Composer: tudo roda via `docker run` com imagens + oficiais.** O `Dockerfile`, o `docker-compose.yml` e o `.devcontainer/` estão + no `.gitignore` e não existem num clone. Os comandos validados estão em + [setup.md](docs/setup.md#comandos). +3. **Rode a suíte nas três versões antes do PR.** O `phpunit.xml` transforma + deprecation em erro (`convertDeprecationsToExceptions`), e o que passa no 7.4 + pode quebrar no 8.4. +4. **Os testes não verificam a request.** Nenhum teste confere URL, verbo, + query ou body enviados. Um `$location` ou um verbo errado passa no CI. Se a + mudança mexe nisso, o teste precisa inspecionar a request + ([sdk.md](docs/sdk.md#testes)). +5. **Endpoints retornam `Http\Response`, não models.** Os models só montam o + payload de request. Para ler a resposta, use `getData()` ou + `getParsedPath('a.b')`. +6. **Mutator é um método com o nome exato do atributo** (`cpf_cnpj()`, + `zipcode()`). Um atributo novo com o mesmo nome de um método existente vira + mutator sem aviso. +7. **A semântica de um campo vem da documentação da API** + ([developers.ipag.com.br](https://developers.ipag.com.br)), nunca do próprio + SDK, que não documenta formato nem default. +8. **Nunca use `origin/develop` como base.** Ela tem commits não mergeados que + colidem com a `master` (os mesmos accessors `subscriptionV2()` e + `paymentLinksV2()` retornando classes diferentes). Todo PR vai para a + `master`. +9. **Quem publica a versão é a tag.** O `composer.json` não tem `version`, e + cada release precisa de entrada no `CHANGELOG.md` e de uma tag anotada no + merge commit ([setup.md](docs/setup.md#versionamento-e-release)). + +## Docs + +| Doc | Vá aqui quando | +|---|---| +| [docs/setup.md](docs/setup.md) | for instalar dependências, rodar testes ou exemplos, entender o CI, versionar ou publicar | +| [docs/sdk.md](docs/sdk.md) | for ler ou alterar o SDK: client, endpoints, DSL de models, mutators, exceções, testes, como adicionar endpoint ou campo | + +O fluxo de execução de tasks (do pickup ao release) está na skill +[`ipag-sdk-php-execution`](.claude/skills/ipag-sdk-php-execution/SKILL.md). + +Fontes canônicas que os docs **não** replicam: +- a documentação da API ([developers.ipag.com.br](https://developers.ipag.com.br)), para campos e regras; +- `composer.json`/`composer.lock`, para versões; +- `.github/workflows/ci-tests.yml`, para o CI; +- `README.md`, para a API pública do SDK. + +## Estado de referência + +`master` em `103e055` (tag `v1.4.1`), `composer.lock` com Guzzle `7.10.0`, +PHPUnit `9.6.34` e `kubinyete/assertation` `1.0.4`. A suíte tinha 461 testes, +todos passando em PHP 7.4, 8.3 e 8.4. + +Se algo não bater, revalide: + +```bash +git rev-parse --short HEAD && git describe --tags +git status --short +``` + +Ao mudar `composer.json`, `phpunit.xml`, `.github/workflows/*` ou `src/Core/*`, +atualize o doc correspondente. diff --git a/docs/sdk.md b/docs/sdk.md new file mode 100644 index 0000000..75901bc --- /dev/null +++ b/docs/sdk.md @@ -0,0 +1,251 @@ +← [CLAUDE.md](../CLAUDE.md) · [setup](setup.md) · **sdk** + +# Como o SDK funciona + +PSR-4: `Ipag\Sdk\` → `src/` e `Ipag\Sdk\Tests\` → `tests/`. + +## Mapa + +``` +IpagClient (src/Core/IpagClient.php) + ├─ IpagEnvironment base URL (LOCAL | SANDBOX | PRODUCTION) + ├─ GuzzleHttpClient Basic auth + header x-api-version + ├─ JsonSerializer + └─ customer(), payment(), paymentLinksV2(), ... -> XxxEndpoint::make() (instância nova a cada chamada) + +XxxEndpoint::create(Model $m) + └─ Endpoint::_POST($m->jsonSerialize(), $query) src/Core/Endpoint.php + └─ Client::request() serializa, loga, chama o Guzzle + └─ Http\Response <- é isto que o consumidor recebe +``` + +## Client e ambiente + +Assinatura: `new IpagClient(string $apiID, string $apiKey, string $environment, +?LoggerInterface $logger = null, string $version = '2')`. + +- **O ambiente é uma whitelist exata.** `IpagEnvironment` só aceita as strings + das constantes `LOCAL` (`'api.ipag.test'`, **sem esquema**), `SANDBOX` + (`'https://sandbox.ipag.com.br'`) e `PRODUCTION` + (`'https://api.ipag.com.br'`). Qualquer outro valor lança + `UnexpectedValueException("The environment must be valid")`, inclusive + `http://api.ipag.test`. +- **Há duas noções de versão, independentes:** + - o header `x-api-version`: é o 5º argumento do construtor (default `'2'`) e + vale para o client inteiro; + - a **rota**: cada classe de endpoint define a sua em `$location`. +- **O logger é PSR-3** (`psr/log` `^1|^2|^3`) e cai em `NullLogger` se for + omitido. Em `debug`, ele loga o **body completo** da request, incluindo + dados de cartão, e o body da resposta. + +## Endpoints + +Cada classe em `src/Endpoint/` estende `Core\Endpoint` e define +`protected string $location` com o **caminho completo** da rota. Os métodos +chamam `_GET/_POST/_PUT/_PATCH/_DELETE($body?, $query, $header, $relativeUrl)`. + +| Família | `$location` | Classes | +|---|---|---| +| Recursos clássicos | `/service/resources/` | Charge, Checkout, Customer, Establishment, FutureTransfer, PaymentLinks, Seller, SellerTransfer, SplitRules, Subscription, SubscriptionPlan, Token, Transaction, Transfer, Voucher, Webhook | +| v2 | `/service/v2/` | PaymentLinksV2, SubscriptionV2 (só create/update), EstablishmentTransaction, EstablishmentPaymentMethods, EstablishmentAntifraud | +| Pagamento | `/service` + relativo (`/payment`, `/consult`, `/capture`, `/cancel`) | Payment | + +Convenções que o código segue: + +- **O ID vai na query, não no path.** `get`, `update` e `delete` passam + `['id' => $id]`, ex.: `PUT /service/v2/payment_links?id=10`. Só + sub-recursos usam segmento (`PaymentLinksV2Endpoint::listTransactions` → + `/{id}/transactions`). +- **Sub-endpoints não aninham o path.** `EstablishmentEndpoint::transaction()` + e `TransferEndpoint::seller()` criam o filho com + `X::make($this->parent, $this->parent)`, então cada filho declara a própria + rota completa. +- **Alguns métodos reescrevem `$this->location` em tempo de execução** + (`SubscriptionEndpoint::unlinkToken` e `_actionsInstallmentPayment`). Isso só + é seguro porque cada accessor do `IpagClient` devolve uma instância nova. +- **Nome de versão 2:** classe `V2Endpoint`, accessor + `V2()` e models em `src/Model/V2/`. Os sub-endpoints de + establishment, que já são v2, não carregam o sufixo. + +## Models + +Os models são **payloads de request**. Cada subclasse de `Model` +(`src/Model/Model.php`) declara o schema e termina com `return $schema->build();`: + +```php +protected function schema(SchemaBuilder $schema): Schema +{ + $schema->string('name')->nullable(); + $schema->int('stock_limit')->nullable(); + $schema->has('address', Address::class)->nullable(); + $schema->hasMany('products', Product::class)->nullable(); + return $schema->build(); +} +``` + +| DSL | O que faz | +|---|---| +| `any`, `string`, `int`, `float`, `bool`, `date($fmt)`, `enum($valores)` | Tipos. `int` rejeita `"1"`, e `float` aceita int mas rejeita string numérica. | +| `has($attr, Classe)`, `hasMany(...)` | Relação com outro model | +| `->nullable()`, `->default($v)`, `->hidden()` | `hidden` tira o atributo do JSON | +| `->array()` / `->list()` | Atributo vira lista do tipo | +| `->limit(n)`, `->truncate(n)`, `->between(min, max)` | Strings | +| `->min()`, `->max()` | Floats | + +- **O construtor recebe um array associativo.** Uma lista (`[0 => ...]`) lança + `\Exception`. +- **Os accessors são escritos à mão e tipados**, ex. + `getX(): ?T { return $this->get('x'); }` e + `setX(?T $x): self { $this->set('x', $x); return $this; }`. Tipo errado num + setter lança `\TypeError`, e há testes que contam com isso. +- **O mutator é um método `protected` com o nome exato do atributo**, que + retorna `new Mutator($getter, $setter)`. O setter recebe + `($value, MutatorContext $ctx)` e falha com `$ctx->raise('msg')` ou + `$ctx->assert($cond, 'msg')`, que lançam `MutatorAttributeException`. Para + validar, o código costuma usar `Kubinyete\Assertation\Assert`. + `Model::loadMutator` descobre o mutator com `is_callable([$this, $nome])`. +- **Serialização:** `jsonSerialize()` emite **todo** atributo não-`hidden`, + **inclusive `null`**, e serializa as relações recursivamente. + - Os models v2 (`PaymentLinkV2`, `SubscriptionV2`, `Plan`, `PlanTrial`) + sobrescrevem o método para tirar os nulos com + `array_filter(..., fn($v) => !is_null($v))`. + - `CheckoutSettings` e os models v1 aninhados (ex. `Customer` dentro de + `SubscriptionV2`) não sobrescrevem, e mandam `null`. +- **Mutator com data** precisa de guarda para `null` antes de + `\DateTime::createFromFormat`. Sem ela, o PHP 8.1+ emite deprecation, que a + suíte transforma em erro. + +## Respostas + +Nenhum endpoint transforma a resposta em model. `Http\Response` expõe: + +- `getData()`: o array decodificado; +- `getParsedPath('attributes.amount')`: lookup com ponto, a partir da raiz do + body (re-decodifica o body a cada chamada); +- `getBody()`: o body cru; +- `getHeaders()` e `getStatusCode()`. + +Body vazio ou JSON que decodifica para string vira `['data' => ]`. +`Model::parse()` e `tryParse()` existem, mas nenhum endpoint os usa. + +## Exceções + +``` +\Exception + ├─ BaseException + │ ├─ HttpException (abstract) getResponse() getStatusCode() getStatusMessage() getErrors() + │ │ ├─ HttpClientException 4xx (Guzzle ClientException) + │ │ ├─ HttpServerException 5xx (Guzzle ServerException) + │ │ └─ HttpTransferException rede (Guzzle ConnectException) + │ └─ ParseException JSON inválido em JsonSerializer + ├─ SchemaException ── SchemaAttributeParseException, SchemaAttributeSerializeException + └─ MutatorException ── MutatorAttributeException +``` + +O mapeamento Guzzle → SDK fica em `GuzzleHttpClient::request()`. Depois, +`Endpoint::request()` **re-embrulha só a `HttpClientException`**: + +- a mensagem vira `response message: "" (status code: N)`; +- os erros saem de `message`, `error` ou `data` do body e vão para + `getErrors()`; +- `statusCode` e `statusMessage` ficam `null`, e o status passa a estar em + `getCode()` e em `getResponse()->getStatusCode()`. + +5xx e erro de rede sobem como vieram. As exceções de schema **não** estendem +`BaseException`, então um `catch (BaseException)` não as pega. + +## Testes + +| Pasta | Base | Padrão | +|---|---|---| +| `tests/Model/` | `PHPUnit\Framework\TestCase` | monta o model por construtor e por setters e confere getters e `jsonSerialize()`. Validação: `expectException(MutatorAttributeException::class)` ou `\TypeError`. | +| `tests/Endpoint/` | `Ipag\Sdk\Tests\IpagClient` | `$this->instanceClient([new Response(200, [], json_encode([...]))])`, depois chama o endpoint e confere `getParsedPath(...)`. Erro: `try/catch`, com `assertInstanceOf(HttpException::class, ...)` e `assertSame(4xx, $e->getCode())`. | +| `tests/Support/` | `TestCase` | credenciais e providers | + +Os fixtures são métodos privados no próprio teste (não há pasta de fixtures). +O `phpunit.xml` é estrito: `failOnRisky`, `failOnWarning`, +`beStrictAboutOutputDuringTests` e deprecation vira exceção. + +**Para verificar a request enviada**, monte o stack com o middleware de +histórico do Guzzle. O `instanceClient()` não expõe isso: + +```php +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Response; +use Ipag\Sdk\Core\IpagEnvironment; +use Ipag\Sdk\Mock\IpagClientMock; + +$history = []; +$stack = HandlerStack::create(new MockHandler([new Response(200, [], '{}')])); +$stack->push(Middleware::history($history)); +$client = new IpagClientMock('test', 'AAA', IpagEnvironment::SANDBOX, '2', ['handler' => $stack]); + +$client->customer()->update($customer, 10); + +$request = $history[0]['request']; +$this->assertSame('PUT', $request->getMethod()); +$this->assertSame('/service/resources/customers', $request->getUri()->getPath()); +$this->assertSame('id=10', $request->getUri()->getQuery()); +$body = json_decode((string) $request->getBody(), true); +``` + +Use `SANDBOX` aqui, não `LOCAL`. Sem esquema, o Guzzle trata +`api.ipag.test/...` inteiro como path, e `getHost()` vem vazio. O +`MockHandler` nunca chega à rede em nenhum dos dois casos. + +**Dados de teste são sintéticos.** Gere CPF e CNPJ válidos pelo dígito +verificador. Nunca use documento, nome ou ID vindos de um caso real. + +## Adicionar um endpoint + +O padrão foi tirado do commit que trouxe links de pagamento e assinatura v2 +(`23bb290`): + +1. `src/Endpoint/V2Endpoint.php` com `$location` e métodos que + recebem o model e chamam `$this->_POST($model->jsonSerialize())` (e + equivalentes). +2. Models em `src/Model/V2/`, com `jsonSerialize()` filtrando os + nulos, como os outros v2. +3. Accessor e `use` em `src/Core/IpagClient.php`. +4. `tests/Endpoint/V2EndpointTest.php` **verificando método, path e + query** (snippet acima) e `tests/Model/...Test.php`. +5. `examples/_v2/NN-.php`. +6. Seção no `README.md`. O `23bb290` não fez isso, e é por isso que o README + não tem nada de v2. + +## Adicionar um campo a um model + +O padrão foi tirado de `4f601a6`: + +1. Adicione o atributo ao `schema()`. Confira na documentação da API o tipo, o + formato e se ele é anulável. +2. Escreva o getter e o setter tipados, e o mutator se houver validação ou + normalização. +3. Atualize o `tests/Model/Test.php` (construtor, setter e serialização) + e o exemplo que monta esse model. +4. Antes de nomear o atributo, verifique se ele não colide com um método do + model, porque senão vira mutator. + +## Notas de leitura + +Bugs latentes em caminhos pouco exercitados. Não corrija de passagem: cada um +merece task própria. + +- `SchemaAttribute.php` importa `Ipag\Sdk\Util\StringUtil`, que não existe. O + erro aparece quando o parse de um atributo array falha. +- `MutatorContext::getAttributeRelativeName()` chama `$target->getName()`. O + `Model` base não tem esse método, e nos models que têm (ex. `Customer`) ele + devolve o atributo `name`, não o nome do model. Só é usado quando o contexto + é criado sem o schema do atributo, o que o `Model` não faz hoje. +- `Endpoint::sanitizeErrorMessage()` pode retornar `null`, que vai direto para + `implode`. Isso é um provável `TypeError` no PHP 8 para uma resposta 4xx com + body inesperado. +- Body 4xx que não é JSON lança `ParseException` de dentro do `catch`, no lugar + da `HttpClientException`. +- `IpagClient::IpagClient()` é um método vazio, resto de construtor no estilo + PHP 4. Não é construtor. +- `IpagClientMock` tem uma assinatura diferente da do `IpagClient` + (`version` em 4º, `configGuzzle` em 5º, sem logger) e monta o header Basic + à mão. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..f337594 --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,142 @@ +← [CLAUDE.md](../CLAUDE.md) · **setup** · [sdk](sdk.md) + +# Setup e operação + +Biblioteca pura: não tem servidor nem banco. O ciclo é instalar dependências, +rodar o PHPUnit e, quando precisar, rodar um exemplo contra a sandbox. + +## Do zero + +Só precisa de Docker. Os comandos usam as imagens oficiais `composer:2` e +`php:-cli` e rodam com o seu usuário (`-u`), para que `vendor/` não +fique com dono `root`. + +```bash +git clone git@github.com:ipagdevs/ipag-sdk-php.git && cd ipag-sdk-php + +# dependências: instala exatamente o composer.lock (resolvido para PHP 7.4.33) +docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer \ + -v "$PWD":/app -w /app composer:2 install --no-interaction + +# suíte nas três versões do CI +for v in 7.4 8.3 8.4; do + docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:$v-cli vendor/bin/phpunit || break +done +``` + +A imagem `composer:2` roda PHP 8.x, mas o `config.platform.php=7.4.33` do +`composer.json` faz o install respeitar o lock resolvido para 7.4. O mesmo +`vendor/` serve para as três versões. + +## Comandos + +Todos a partir da raiz do repo. Troque `7.4` pela versão desejada. + +| Para | Rode | +|---|---| +| Instalar dependências | `docker run --rm -u "$(id -u):$(id -g)" -e COMPOSER_HOME=/tmp/composer -v "$PWD":/app -w /app composer:2 install --no-interaction` | +| Suíte inteira | `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app -w /app php:7.4-cli vendor/bin/phpunit` | +| Um arquivo | `... php:7.4-cli vendor/bin/phpunit tests/Model/CustomerTest.php` | +| Um teste | `... php:7.4-cli vendor/bin/phpunit --filter testShouldCreateCustomerObjectWithConstructorSuccessfully` | +| Saída legível | acrescente `--testdox` | +| Um exemplo | `... php:7.4-cli php examples/customer/00-customer-create.php` (credenciais: ver [Exemplos](#exemplos)) | +| Script avulso | `docker run --rm -u "$(id -u):$(id -g)" -v "$PWD":/app:ro -v /caminho/do/script:/s:ro php:7.4-cli php /s/x.php` | + +**Cobertura não roda nas imagens oficiais.** Elas não trazem Xdebug nem PCOV. +O CI gera o `clover.xml` com Xdebug. Para ver cobertura, abra o PR e olhe o +relatório do CI. + +**Não troque o `composer install` por `composer update`.** O `update` +re-resolve tudo e, se o pin de plataforma for removido ou contornado, traz +pacotes que exigem PHP 8. Mudança de dependência é decisão de task, não efeito +colateral. + +### Por que `docker run` e não o compose local + +Algumas máquinas têm `Dockerfile`, `docker-compose.yml`, `.docker/php.ini` e +`.devcontainer/`, mas eles estão no `.gitignore` e **não existem num clone**. +Não documente nem dependa deles: um comando que só funciona com esses arquivos +quebra para o próximo dev ou agente. As imagens oficiais cobrem tudo o que a +suíte precisa (as extensões padrão do `php:*-cli` bastam). + +## Exemplos + +`examples//NN-.php` são scripts que fazem chamadas **reais**. +Todos fazem `require_once` de `examples/config.php`, que monta o client com +`'apiID'`, `'apiKey'` e `IpagEnvironment::SANDBOX`. + +Para rodar um exemplo: + +1. Edite `examples/config.php` com credenciais de **sandbox**. +2. Rode-o como na tabela acima. O container tem rede por padrão. +3. **Desfaça a edição antes de commitar.** O arquivo é versionado e o repo é + público. Antes de qualquer `git add`, confira que + `git diff --quiet examples/config.php` não retorna nada. + +Alguns exemplos usam IDs fixos (ex. `examples/subscription_v2/02-*.php`) que +só existem na conta de quem os escreveu. Troque pelos da sua conta. + +## CI + +`.github/workflows/ci-tests.yml` é o único workflow. + +| | | +|---|---| +| Dispara em | push em `master` e `develop`, e PR para `master` | +| Matriz | PHP `7.4`, `8.3` e `8.4` (`ubuntu-latest`, `shivammathur/setup-php`, Xdebug) | +| Instala | `composer install --prefer-dist`, que respeita o lock e o pin de plataforma | +| Roda | `php vendor/bin/phpunit --color --testdox --coverage-clover clover.xml tests` | +| Cobertura | Code Climate, só no job 7.4 e só com o secret `CODE_CLIMATE_ID` | + +Não há lint nem análise estática no CI, nem configuração deles no repo. + +## Versionamento e release + +O `composer.json` **não tem campo `version`**: o Packagist publica cada tag do +GitHub como uma versão. Consumidor com pin conservador (`~1.4.0`, `1.4.*`) +recebe patch sem revisar, então decida o bump pelo **conteúdo** do release: + +| O release contém | Bump | +|---|---| +| quebra de compatibilidade: método público removido ou renomeado, assinatura alterada, mudança de serialização da qual consumidor depende | MAJOR | +| **qualquer feature nova**: endpoint, método, campo de model, enum | **MINOR** | +| só correção, compatibilidade de PHP ou refactor sem efeito observável | PATCH | + +O release são **três artefatos**, e esquecer um deixa o release inconsistente: + +| Artefato | Onde | +|---|---| +| entrada do release | `CHANGELOG.md`, no topo (`## vX.Y.Z - AAAA-MM-DD`, PT-BR), **no mesmo PR da mudança** | +| tag | anotada (`git tag -a`), no **merge commit do PR na `master`**, mensagem `Release vX.Y.Z` | +| GitHub Release | na tag, com o texto da entrada do `CHANGELOG.md` | + +```bash +# só depois do PR mergear na master +git checkout master && git pull origin master +git log -1 --oneline # confirme: é o merge commit do PR +git tag -a vX.Y.Z -m "Release vX.Y.Z" +git push origin vX.Y.Z +``` + +O histórico de tags é irregular, e **não siga** os desvios: +- `1.4.0` saiu sem o `v`; +- `v1.4.1` é lightweight; +- `v1.3.1` aponta para um commit direto, sem PR. + +O padrão é o de `v1.3.0`: tag anotada, com `v`, no merge commit. + +Tag publicada **não se move**. Se um número errado já saiu, corrija no release +seguinte, nunca reescrevendo a tag: quem já resolveu a dependência fixou o +commit no `composer.lock`. + +O `CHANGELOG.md` começou na primeira versão depois de `v1.4.1`. As versões +anteriores só têm a tag e os commits. + +### Chegar ao consumidor é outro passo + +Publicar a tag não atualiza ninguém. O módulo Magento 2 +([`ipagdevs/magento2`](https://github.com/ipagdevs/magento2)) fixa a versão +exata do SDK no `composer.json`, então a correção só chega lá com um PR no +próprio módulo, e as integrações de lojistas só a recebem quando atualizam a +dependência. Se a task nasceu de um caso de cliente, o release só fecha o ciclo +quando quem acompanha o caso é avisado da versão.