From d62fda941589e508e5118d3dcc81622b7de1268f Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Sun, 4 Oct 2026 00:24:19 -0300 Subject: [PATCH 1/4] feat(balcao): estorno de lancamento de pontos Task 4 do backlog: o lojista estorna pelo extrato um lancamento das ultimas 24 h. O estorno vira um registro novo (reversal) apontando para o lancamento, nada e apagado, e o saldo nunca fica negativo. Migration 004. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- README.md | 1 + database/migrations/004_estorno.sql | 16 +++++ database/schema.sql | 8 ++- docs/db/schema-explanation.md | 5 +- public/css/app.css | 5 ++ src/Controllers/MerchantController.php | 24 +++++++ src/Models/LoyaltyCardModel.php | 85 ++++++++++++++++++++-- src/Models/PointsLogModel.php | 16 +++-- src/Support/helpers.php | 10 +++ tests/Feature/ReversalScreenTest.php | 99 ++++++++++++++++++++++++++ tests/Integration/ReversalTest.php | 95 ++++++++++++++++++++++++ views/merchant/reports.php | 11 +-- views/merchant/statement.php | 24 ++++--- views/partials/flash.php | 5 ++ 15 files changed, 374 insertions(+), 34 deletions(-) create mode 100644 database/migrations/004_estorno.sql create mode 100644 tests/Feature/ReversalScreenTest.php create mode 100644 tests/Integration/ReversalTest.php diff --git a/CLAUDE.md b/CLAUDE.md index a17b1c3..6b54b8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ composer test -- --testsuite Unit # só os testes sem banco tsc # compila src/ts -> public/js (tsconfig.json) ``` -Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já existente: rode, em ordem e uma vez cada, as migrations de `database/migrations/` que ainda não rodou (`001_mvp`, `002_rate_limit`, `003_lgpd`); o `schema.sql` sempre reflete o estado final. Credenciais em `.env` (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`; `APP_URL` opcional: endereço público usado no QR do cartaz, vazio usa o host da requisição). No ambiente local do autor o PHP e o MySQL vêm do XAMPP (`C:\xampp\mysql\bin\mysql.exe`). +Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já existente: rode, em ordem e uma vez cada, as migrations de `database/migrations/` que ainda não rodou (`001_mvp`, `002_rate_limit`, `003_lgpd`, `004_estorno`); o `schema.sql` sempre reflete o estado final. Credenciais em `.env` (`DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS`; `APP_URL` opcional: endereço público usado no QR do cartaz, vazio usa o host da requisição). No ambiente local do autor o PHP e o MySQL vêm do XAMPP (`C:\xampp\mysql\bin\mysql.exe`). **Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators, sem banco), `Integration` (models e `RateLimiter` contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso. @@ -46,7 +46,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas. - `loyalty_cards` = par (merchant, customer) único. Guarda o que o cliente deu **a esta loja** (`customer_name`, `consent_at`, `consent_version`), o saldo (`current_points`, `total_accumulated`) e `anonymized_at`. `findOrCreate` usa `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` e nunca troca nome nem consentimento de cartão existente. - **LGPD (regra da task 11, `docs/adr/002-lgpd-dados-por-loja.md`)**: nenhuma tela de uma loja mostra dado vindo de outra. Telefone sem cartão nesta loja vai sempre para o cadastro rápido, seja novo ou de outra loja: nunca crie caminho que diferencie os dois. Consentimento é gravado no cadastro com `Privacy::VERSION` (mudou o texto de `views/privacy.php` de forma relevante, troque a versão). Exclusão = `LoyaltyCardModel::anonymize` (cartão fica só para os relatórios, telefone some quando não há mais cartão). -- `points_log` é o histórico (`earn`/`redeem`, `reward_id` nos resgates). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. +- `points_log` é o histórico (`earn`/`redeem`/`reversal`, `reward_id` nos resgates, `reverses_id` nos estornos). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem` / `reverse`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. - `merchants` guarda CPF **ou** CNPJ (normalizados, UNIQUE). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). **Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica). diff --git a/README.md b/README.md index 45fde79..0aa093f 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ O arquivo [database/schema.sql](database/schema.sql) cria automaticamente: > `mysql -u root -p fidelityx < database/migrations/001_mvp.sql` (bancos anteriores ao MVP) > `mysql -u root -p fidelityx < database/migrations/002_rate_limit.sql` (limite de tentativas de login e da consulta pública) > `mysql -u root -p fidelityx < database/migrations/003_lgpd.sql` (dados do cliente por loja, consentimento e código público da loja) +> `mysql -u root -p fidelityx < database/migrations/004_estorno.sql` (estorno de lançamento de pontos) > > Detalhes das tabelas em [docs/db/schema-explanation.md](docs/db/schema-explanation.md). diff --git a/database/migrations/004_estorno.sql b/database/migrations/004_estorno.sql new file mode 100644 index 0000000..145f22e --- /dev/null +++ b/database/migrations/004_estorno.sql @@ -0,0 +1,16 @@ +-- fidelityx - migration 004 (estorno de lancamento de pontos, task 4) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- mysql -u root -p fidelityx < database/migrations/004_estorno.sql + +USE fidelityx; + +-- estorno vira um registro novo no historico (nada e apagado), apontando para o lancamento estornado. +-- o UNIQUE em reverses_id garante que um lancamento so e estornado uma vez, mesmo com dois cliques ao mesmo tempo. +ALTER TABLE points_log + MODIFY type ENUM('earn', 'redeem', 'reversal') NOT NULL, + ADD COLUMN reverses_id BIGINT UNSIGNED NULL AFTER reward_id, + ADD UNIQUE KEY uq_points_log_reverses (reverses_id), + ADD CONSTRAINT fk_points_log_reverses + FOREIGN KEY (reverses_id) REFERENCES points_log(id) + ON DELETE CASCADE + ON UPDATE CASCADE; diff --git a/database/schema.sql b/database/schema.sql index da4b910..66fabe9 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -137,10 +137,11 @@ CREATE TABLE IF NOT EXISTS points_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_id BIGINT UNSIGNED NOT NULL, - type ENUM('earn', 'redeem') NOT NULL, + type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn quantity INT NOT NULL, description VARCHAR(255) NOT NULL, reward_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'redeem' + reverses_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'reversal': o lancamento estornado -- para futuras implementações (NULL) responsible_user VARCHAR(255) NULL, @@ -153,6 +154,7 @@ CREATE TABLE IF NOT EXISTS points_log ( KEY idx_points_log_type (type), KEY idx_points_log_reward (reward_id), + UNIQUE KEY uq_points_log_reverses (reverses_id), -- um lancamento so pode ser estornado uma vez CONSTRAINT fk_points_log_card FOREIGN KEY (card_id) REFERENCES loyalty_cards(id) @@ -161,6 +163,10 @@ CREATE TABLE IF NOT EXISTS points_log ( CONSTRAINT fk_points_log_reward FOREIGN KEY (reward_id) REFERENCES rewards(id) ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT fk_points_log_reverses + FOREIGN KEY (reverses_id) REFERENCES points_log(id) + ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index f89e280..b32ddf1 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -1,7 +1,7 @@ # Schema do banco — FidelityX MySQL 8.0+ / MariaDB 10.4+ · InnoDB · `utf8mb4_unicode_ci` · colunas `TIMESTAMP` (guardadas em UTC pelo MySQL e lidas no fuso da aplicação, `APP_TIMEZONE`, padrão `America/Sao_Paulo`). -Arquivo: [`database/schema.sql`](../../database/schema.sql). Bancos já existentes: rodar, em ordem, as migrations de [`database/migrations/`](../../database/migrations/) que ainda não rodaram (`001_mvp`, `002_rate_limit`, `003_lgpd`). +Arquivo: [`database/schema.sql`](../../database/schema.sql). Bancos já existentes: rodar, em ordem, as migrations de [`database/migrations/`](../../database/migrations/) que ainda não rodaram (`001_mvp`, `002_rate_limit`, `003_lgpd`, `004_estorno`). ## Visão geral @@ -58,8 +58,9 @@ merchants 1───N loyalty_cards N───1 customers ### `points_log` — histórico | Coluna | Observação | |---|---| -| `type` | `earn` (ganho) ou `redeem` (resgate). `quantity` é sempre positivo. | +| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho). `quantity` é sempre positivo. | | `reward_id` | Preenchido em resgates. Vira `NULL` se o prêmio for apagado. | +| `reverses_id` | Preenchido em estornos (`reversal`): o lançamento estornado. `UNIQUE`, então cada lançamento é estornado no máximo uma vez. | | `ip_address` | IP de quem fez a operação. | ### `rate_limit_hits` — tentativas (limite de abuso) diff --git a/public/css/app.css b/public/css/app.css index cf7b4ae..cc93b45 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -280,6 +280,11 @@ th.num { color: var(--primary-hover); } +.badge-reversal { + background: var(--warning-soft); + color: var(--warning-text); +} + .btn-secondary { min-height: 44px; background: transparent; diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 44601d4..f9fefa0 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -535,6 +535,24 @@ private function handleCustomer($merchantId) { redirect('merchant/dashboard', ['success' => 'cliente_excluido']); } + // estorno de lancamento (do extrato ou do Desfazer da confirmacao). volta pra tela de onde veio. + if (($_POST['action'] ?? '') === 'reverse') { + $back = ($_POST['back'] ?? '') === 'statement' ? 'merchant/statement' : 'merchant/customer'; + $logId = filter_var($_POST['log_id'] ?? '', FILTER_VALIDATE_INT); + // o lancamento precisa ser do cartao deste cliente (o model confere tambem a loja) + $result = $logId && $this->logBelongsToCard($logId, (int)$card['id']) + ? $cardModel->reverse($logId, $merchantId) + : 'not_found'; + + redirect($back, ['phone' => $phone] + match ($result) { + 'ok' => ['success' => 'lancamento_estornado'], + 'already' => ['error' => 'estorno_repetido'], + 'expired' => ['error' => 'estorno_expirado'], + 'insufficient' => ['error' => 'estorno_sem_saldo'], + default => ['error' => 'estorno_invalido'], + }); + } + if (($_POST['action'] ?? '') === 'redeem') { $rewardId = filter_var($_POST['reward_id'] ?? '', FILTER_VALIDATE_INT); $reward = $rewardId ? (new RewardModel($this->db))->findActiveForMerchant($rewardId, $merchantId) : null; @@ -565,6 +583,12 @@ private function handleCustomer($merchantId) { redirect('merchant/customer', ['phone' => $phone, 'success' => 'pontos_lancados']); } + private function logBelongsToCard(int $logId, int $cardId): bool { + $stmt = $this->db->prepare('SELECT 1 FROM points_log WHERE id = :id AND card_id = :card_id'); + $stmt->execute([':id' => $logId, ':card_id' => $cardId]); + return (bool)$stmt->fetchColumn(); + } + // criar, ativar/desativar ou excluir premio private function handleRewards($merchantId, RewardModel $rewardModel) { Csrf::verify(); diff --git a/src/Models/LoyaltyCardModel.php b/src/Models/LoyaltyCardModel.php index 558f2d7..ee6c400 100644 --- a/src/Models/LoyaltyCardModel.php +++ b/src/Models/LoyaltyCardModel.php @@ -47,8 +47,8 @@ public function findByMerchantAndPhone($merchantId, $phone) { return $stmt->fetch(\PDO::FETCH_ASSOC); } - // soma pontos no cartao e registra no historico (tudo ou nada) - public function addPoints($cardId, $points, $description) { + // soma pontos no cartao e registra no historico (tudo ou nada). devolve o id do lancamento. + public function addPoints($cardId, $points, $description): int { $this->db->beginTransaction(); try { $stmt = $this->db->prepare( @@ -60,9 +60,76 @@ public function addPoints($cardId, $points, $description) { ); $stmt->execute([':p1' => $points, ':p2' => $points, ':id' => $cardId]); - $this->log($cardId, 'earn', $points, $description, null); + $logId = $this->log($cardId, 'earn', $points, $description, null); $this->db->commit(); + return $logId; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + // estorno de um lancamento de pontos (digitou 500 em vez de 50). nada e apagado: o estorno vira uma + // linha nova 'reversal' que aponta pro lancamento, e o saldo e o total acumulado voltam ao que eram. + // so vale para lancamento (earn) desta loja, feito nas ultimas REVERSAL_WINDOW_HOURS horas, ainda nao estornado, + // e quando o saldo atual cobre os pontos (se o cliente ja gastou, o saldo ficaria negativo). + // devolve 'ok', 'not_found', 'already', 'expired' ou 'insufficient'. + public const REVERSAL_WINDOW_HOURS = 24; + + public function reverse($logId, $merchantId): string { + $this->db->beginTransaction(); + try { + $stmt = $this->db->prepare( + "SELECT pl.id, pl.card_id, pl.quantity, pl.description, + pl.created_at >= NOW() - INTERVAL " . self::REVERSAL_WINDOW_HOURS . " HOUR AS recent, + EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id) AS reversed + FROM points_log pl + JOIN loyalty_cards lc ON lc.id = pl.card_id + WHERE pl.id = :id AND lc.merchant_id = :merchant_id AND pl.type = 'earn' AND lc.anonymized_at IS NULL" + ); + $stmt->execute([':id' => $logId, ':merchant_id' => $merchantId]); + $entry = $stmt->fetch(\PDO::FETCH_ASSOC); + + $problem = match (true) { + !$entry => 'not_found', + (bool)$entry['reversed'] => 'already', + !$entry['recent'] => 'expired', + default => null, + }; + if ($problem) { + $this->db->rollBack(); + return $problem; + } + + // trava o cartao: um resgate ao mesmo tempo nao consegue gastar os pontos que estao sendo estornados + $stmt = $this->db->prepare('SELECT current_points FROM loyalty_cards WHERE id = :id FOR UPDATE'); + $stmt->execute([':id' => $entry['card_id']]); + $quantity = (int)$entry['quantity']; + if ((int)$stmt->fetchColumn() < $quantity) { + $this->db->rollBack(); + return 'insufficient'; + } + + $this->db->prepare( + 'UPDATE loyalty_cards + SET current_points = current_points - :p1, total_accumulated = total_accumulated - :p2, + last_use_at = CURRENT_TIMESTAMP + WHERE id = :id' + )->execute([':p1' => $quantity, ':p2' => $quantity, ':id' => $entry['card_id']]); + + $description = mb_substr('Estorno: ' . $entry['description'], 0, 255); + $this->log($entry['card_id'], 'reversal', $quantity, $description, null, (int)$entry['id']); + + $this->db->commit(); + return 'ok'; + } catch (\PDOException $e) { + $this->db->rollBack(); + // dois estornos do mesmo lancamento ao mesmo tempo: o UNIQUE barra o segundo + if (($e->errorInfo[1] ?? null) === 1062) { + return 'already'; + } + throw $e; } catch (\Throwable $e) { $this->db->rollBack(); throw $e; @@ -185,7 +252,7 @@ public function anonymize($cardId, $merchantId): bool { )->execute([':id' => $cardId]); $this->db->prepare( - "UPDATE points_log SET description = 'Cliente excluído' WHERE card_id = :id AND type = 'earn'" + "UPDATE points_log SET description = 'Cliente excluído' WHERE card_id = :id AND type IN ('earn', 'reversal')" )->execute([':id' => $cardId]); if ($customerId !== null) { @@ -204,10 +271,11 @@ public function anonymize($cardId, $merchantId): bool { } } - private function log($cardId, $type, $quantity, $description, $rewardId) { + // grava uma linha no historico e devolve o id dela + private function log($cardId, $type, $quantity, $description, $rewardId, $reversesId = null): int { $stmt = $this->db->prepare( - 'INSERT INTO points_log (card_id, type, quantity, description, reward_id, ip_address) - VALUES (:card_id, :type, :quantity, :description, :reward_id, :ip)' + 'INSERT INTO points_log (card_id, type, quantity, description, reward_id, reverses_id, ip_address) + VALUES (:card_id, :type, :quantity, :description, :reward_id, :reverses_id, :ip)' ); $stmt->execute([ ':card_id' => $cardId, @@ -215,7 +283,10 @@ private function log($cardId, $type, $quantity, $description, $rewardId) { ':quantity' => $quantity, ':description' => $description, ':reward_id' => $rewardId, + ':reverses_id' => $reversesId, ':ip' => $_SERVER['REMOTE_ADDR'] ?? null, ]); + + return (int)$this->db->lastInsertId(); } } diff --git a/src/Models/PointsLogModel.php b/src/Models/PointsLogModel.php index 60534e7..bb26268 100644 --- a/src/Models/PointsLogModel.php +++ b/src/Models/PointsLogModel.php @@ -32,10 +32,14 @@ public function pageByMerchant($merchantId, int $limit, int $offset) { // extrato de um cartao: movimentacoes do cliente nesta loja, da mais recente pra mais antiga public function pageByCard($cardId, int $limit, int $offset) { - $sql = 'SELECT type, quantity, description, created_at - FROM points_log - WHERE card_id = :card_id - ORDER BY created_at DESC, id DESC + // can_reverse: lancamento que ainda pode ser estornado (o model confere tudo de novo no POST) + $sql = 'SELECT pl.id, pl.type, pl.quantity, pl.description, pl.created_at, + (pl.type = \'earn\' + AND pl.created_at >= NOW() - INTERVAL ' . LoyaltyCardModel::REVERSAL_WINDOW_HOURS . ' HOUR + AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)) AS can_reverse + FROM points_log pl + WHERE pl.card_id = :card_id + ORDER BY pl.created_at DESC, pl.id DESC LIMIT ' . (int)$limit . ' OFFSET ' . (int)$offset; $stmt = $this->db->prepare($sql); $stmt->execute([':card_id' => $cardId]); @@ -64,9 +68,9 @@ public function countByMerchant($merchantId): int { public function statsByMerchant($merchantId) { $sql = "SELECT (SELECT COUNT(*) FROM loyalty_cards WHERE merchant_id = :m1 AND anonymized_at IS NULL) AS customers, - (SELECT COALESCE(SUM(pl.quantity), 0) FROM points_log pl + (SELECT COALESCE(SUM(CASE pl.type WHEN 'earn' THEN pl.quantity ELSE -pl.quantity END), 0) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id - WHERE lc.merchant_id = :m2 AND pl.type = 'earn') AS points_issued, + WHERE lc.merchant_id = :m2 AND pl.type IN ('earn', 'reversal')) AS points_issued, (SELECT COUNT(*) FROM points_log pl JOIN loyalty_cards lc ON lc.id = pl.card_id WHERE lc.merchant_id = :m3 AND pl.type = 'redeem') AS redemptions, diff --git a/src/Support/helpers.php b/src/Support/helpers.php index 183598b..d297b4f 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -44,3 +44,13 @@ function format_datetime($value): string { function url(string $route, array $params = []): string { return '/index.php?' . http_build_query(array_merge(['url' => $route], $params)); } + +// como cada tipo de movimentacao aparece no extrato e nos relatorios: [rotulo, classe do selo, sinal] +function log_type_view(string $type): array { + return match ($type) { + 'earn' => ['Ganhou', 'badge-earn', '+'], + 'redeem' => ['Resgatou', 'badge-redeem', '−'], + 'reversal' => ['Estorno', 'badge-reversal', '−'], + default => [$type, '', ''], + }; +} diff --git a/tests/Feature/ReversalScreenTest.php b/tests/Feature/ReversalScreenTest.php new file mode 100644 index 0000000..5ef9855 --- /dev/null +++ b/tests/Feature/ReversalScreenTest.php @@ -0,0 +1,99 @@ +createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + $id = (new LoyaltyCardModel($this->db))->addPoints($card, 500, 'Compra errada'); + $this->loginAs('loja@teste.test'); + + $this->get(self::STATEMENT); + $this->assertStringContainsString('name="log_id" value="' . $id . '"', $this->lastBody); + + [, $location] = $this->post( + 'merchant/customer', + ['action' => 'reverse', 'back' => 'statement', 'phone' => '11922220002', 'log_id' => $id], + true, + self::STATEMENT + ); + $this->assertSame(self::STATEMENT . '&success=lancamento_estornado', $location); + $this->assertSame(0, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + + // o extrato mostra o estorno e o lancamento nao oferece mais o botao + $this->get(self::STATEMENT); + $this->assertStringContainsString('badge-reversal">Estorno', $this->lastBody); + $this->assertStringContainsString('Estorno: Compra errada', $this->lastBody); + $this->assertStringNotContainsString('name="log_id"', $this->lastBody); + } + + public function testErrosVoltamComMensagem(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + $reward = $this->createReward($merchant, 'Cafe', 20); + $cards = new LoyaltyCardModel($this->db); + $id = $cards->addPoints($card, 30, 'Compra'); + $cards->redeem($card, $reward); + $this->loginAs('loja@teste.test'); + + $reverse = fn($logId) => $this->post( + 'merchant/customer', + ['action' => 'reverse', 'back' => 'statement', 'phone' => '11922220002', 'log_id' => $logId], + true, + self::STATEMENT + )[1]; + + $this->assertSame(self::STATEMENT . '&error=estorno_sem_saldo', $reverse($id)); + $this->assertSame(self::STATEMENT . '&error=estorno_invalido', $reverse('abc')); + } + + public function testNaoEstornaLancamentoDeOutroClienteNemDeOutraLoja(): void { + $lojaA = $this->createMerchant('a@teste.test'); + $this->createMerchant('b@teste.test'); + $cardA = $this->createCard($lojaA, 'Bia', '11922220002'); + $idA = (new LoyaltyCardModel($this->db))->addPoints($cardA, 30, 'Compra'); + + // loja B, com um cliente proprio, tenta estornar o lancamento da loja A pelo id + $this->loginAs('b@teste.test'); + $this->post('merchant/customer-new', ['phone' => '11933330003', 'name' => 'Caio', 'consent' => '1'], true, 'merchant/customer-new&phone=11933330003'); + [, $location] = $this->post( + 'merchant/customer', + ['action' => 'reverse', 'phone' => '11933330003', 'log_id' => $idA], + true, + 'merchant/customer&phone=11933330003' + ); + $this->assertSame('merchant/customer&phone=11933330003&error=estorno_invalido', $location); + $this->assertSame(30, (int)$this->scalar('SELECT current_points FROM loyalty_cards WHERE id = ?', [$cardA])); + } + + public function testEstornoExigeCsrf(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + $id = (new LoyaltyCardModel($this->db))->addPoints($card, 30, 'Compra'); + $this->loginAs('loja@teste.test'); + + [$status] = $this->post('merchant/customer', ['action' => 'reverse', 'phone' => '11922220002', 'log_id' => $id], false); + $this->assertSame(403, $status); + $this->assertSame(30, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + } + + public function testRelatorioMostraOEstornoEDescontaDosPontosEmitidos(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $card = $this->createCard($merchant, 'Bia', '11922220002'); + $cards = new LoyaltyCardModel($this->db); + $cards->addPoints($card, 50, 'Compra'); + $cards->reverse($cards->addPoints($card, 500, 'Compra errada'), $merchant); + $this->loginAs('loja@teste.test'); + + $this->get('merchant/reports'); + $this->assertStringContainsString('badge-reversal">Estorno', $this->lastBody); + $this->assertMatchesRegularExpression('#stat-value">50\s*
Pontos emitidos#', $this->lastBody); + } +} diff --git a/tests/Integration/ReversalTest.php b/tests/Integration/ReversalTest.php new file mode 100644 index 0000000..df29acb --- /dev/null +++ b/tests/Integration/ReversalTest.php @@ -0,0 +1,95 @@ +cards = new LoyaltyCardModel($this->db); + $this->merchant = $this->createMerchant(); + $this->card = $this->createCard($this->merchant, 'Bia', '11922220002'); + } + + private function balance(): array { + return $this->db->query("SELECT current_points, total_accumulated FROM loyalty_cards WHERE id = {$this->card}")->fetch(); + } + + public function testEstornoDevolveSaldoETotalENadaEApagado(): void { + $this->cards->addPoints($this->card, 50, 'Compra'); + $errado = $this->cards->addPoints($this->card, 500, 'Compra errada'); + + $this->assertSame('ok', $this->cards->reverse($errado, $this->merchant)); + + $this->assertSame(['current_points' => 50, 'total_accumulated' => 50], array_map('intval', $this->balance())); + // o lancamento errado continua no historico, e o estorno aponta pra ele + $this->assertSame(3, (int)$this->scalar('SELECT COUNT(*) FROM points_log')); + $estorno = $this->db->query("SELECT type, quantity, description, reverses_id FROM points_log WHERE type = 'reversal'")->fetch(); + $this->assertSame(500, (int)$estorno['quantity']); + $this->assertSame($errado, (int)$estorno['reverses_id']); + $this->assertSame('Estorno: Compra errada', $estorno['description']); + } + + public function testNaoEstornaDuasVezes(): void { + $id = $this->cards->addPoints($this->card, 30, 'Compra'); + + $this->assertSame('ok', $this->cards->reverse($id, $this->merchant)); + $this->assertSame('already', $this->cards->reverse($id, $this->merchant)); + $this->assertSame(0, (int)$this->balance()['current_points']); + } + + public function testSaldoNuncaFicaNegativo(): void { + $reward = $this->createReward($this->merchant, 'Cafe', 20); + $id = $this->cards->addPoints($this->card, 30, 'Compra'); + $this->cards->redeem($this->card, $reward); // saldo 10, menor que os 30 do lancamento + + $this->assertSame('insufficient', $this->cards->reverse($id, $this->merchant)); + $this->assertSame(10, (int)$this->balance()['current_points']); + $this->assertSame(0, (int)$this->scalar("SELECT COUNT(*) FROM points_log WHERE type = 'reversal'")); + } + + public function testSoLancamentoRecente(): void { + $id = $this->cards->addPoints($this->card, 30, 'Compra'); + $this->db->exec("UPDATE points_log SET created_at = NOW() - INTERVAL " . (LoyaltyCardModel::REVERSAL_WINDOW_HOURS + 1) . " HOUR WHERE id = $id"); + + $this->assertSame('expired', $this->cards->reverse($id, $this->merchant)); + } + + public function testSoLancamentoDeGanhoDestaLoja(): void { + $reward = $this->createReward($this->merchant, 'Cafe', 20); + $ganho = $this->cards->addPoints($this->card, 30, 'Compra'); + $this->cards->redeem($this->card, $reward); + $resgate = (int)$this->scalar("SELECT id FROM points_log WHERE type = 'redeem'"); + + $outraLoja = $this->createMerchant('outra@teste.test'); + + $this->assertSame('not_found', $this->cards->reverse($resgate, $this->merchant), 'resgate nao se estorna'); + $this->assertSame('not_found', $this->cards->reverse($ganho, $outraLoja), 'outra loja nao estorna'); + $this->assertSame('not_found', $this->cards->reverse(999999, $this->merchant)); + } + + public function testPontosEmitidosDescontamOEstorno(): void { + $this->cards->addPoints($this->card, 50, 'Compra'); + $id = $this->cards->addPoints($this->card, 500, 'Compra errada'); + $this->cards->reverse($id, $this->merchant); + + $this->assertSame(50, (int)(new PointsLogModel($this->db))->statsByMerchant($this->merchant)['points_issued']); + } + + public function testExclusaoDoClienteLimpaADescricaoDoEstornoTambem(): void { + $id = $this->cards->addPoints($this->card, 30, 'Compra da Bia'); + $this->cards->reverse($id, $this->merchant); + + $this->cards->anonymize($this->card, $this->merchant); + + $this->assertSame(0, (int)$this->scalar("SELECT COUNT(*) FROM points_log WHERE description LIKE '%Bia%'")); + } +} diff --git a/views/merchant/reports.php b/views/merchant/reports.php index 40c75dc..8c8ae2f 100644 --- a/views/merchant/reports.php +++ b/views/merchant/reports.php @@ -42,14 +42,9 @@ - - - Ganhou - - Resgatou - - - + + + diff --git a/views/merchant/statement.php b/views/merchant/statement.php index 1119a6d..2b7ad4b 100644 --- a/views/merchant/statement.php +++ b/views/merchant/statement.php @@ -21,21 +21,29 @@
- + - + + + - - diff --git a/views/partials/flash.php b/views/partials/flash.php index 97e6e24..db21f10 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -25,6 +25,10 @@ 'premio_invalido' => 'Prêmio inválido ou inativo.', 'saldo_insuficiente' => 'Saldo de pontos insuficiente para este prêmio.', 'confirmacao_obrigatoria' => 'Marque a confirmação de que o cliente pediu a exclusão.', + 'estorno_invalido' => 'Lançamento não encontrado para este cliente.', + 'estorno_repetido' => 'Este lançamento já foi estornado.', + 'estorno_expirado' => 'Só dá para estornar lançamentos das últimas 24 horas.', + 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', ], 'success' => [ 'cadastrado' => 'Cadastro realizado! Faça login para continuar.', @@ -40,6 +44,7 @@ 'resgate_realizado' => 'Resgate realizado com sucesso.', 'consentimento_registrado' => 'Consentimento registrado.', 'cliente_excluido' => 'Dados do cliente excluídos.', + 'lancamento_estornado' => 'Lançamento estornado. Os pontos saíram do saldo.', ], ]; From c3c17d336f0f450408323c52418bb94795fa229c Mon Sep 17 00:00:00 2001 From: "Emanuel G. Sousa" Date: Sun, 4 Oct 2026 00:26:52 -0300 Subject: [PATCH 2/4] feat(balcao): confirmacao do lancamento com novo saldo e Desfazer Task 5 do backlog: depois de lancar, a tela do cliente mostra os pontos lancados, o saldo novo e quanto falta para o proximo premio, com um botao Desfazer por 30 s (usa o estorno da task 4). Co-Authored-By: Claude Opus 5.5 --- public/css/app.css | 18 ++++++ src/Controllers/MerchantController.php | 24 +++++-- src/Models/PointsLogModel.php | 12 ++++ tests/Feature/BalcaoFlowTest.php | 2 +- tests/Feature/LaunchConfirmationTest.php | 82 ++++++++++++++++++++++++ views/merchant/customer.php | 39 +++++++++++ views/partials/flash.php | 1 - 7 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/LaunchConfirmationTest.php diff --git a/public/css/app.css b/public/css/app.css index cc93b45..83ac601 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -755,3 +755,21 @@ span.btn-secondary { font-size: 1.25rem; letter-spacing: 0.15em; } + +/* confirmacao do lancamento (novo saldo, quanto falta e Desfazer) */ +.launch-confirm { + border-color: var(--success); + background: var(--success-soft); +} + +.launch-confirm p { + margin-bottom: 0.5rem; +} + +.launch-confirm-title strong:first-child { + color: var(--success-text); +} + +.launch-undo { + margin-top: 0.75rem; +} diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index f9fefa0..3044692 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -36,6 +36,10 @@ class MerchantController { private const MAX_POINTS_PER_ENTRY = 10000; private const PER_PAGE = 20; + // confirmacao do lancamento: o botao Desfazer fica UNDO_SECONDS na tela; o aviso so aparece + // se o lancamento tiver ate UNDO_BANNER_SECONDS (o estorno em si vale por 24 h, pelo extrato) + private const UNDO_SECONDS = 30; + private const UNDO_BANNER_SECONDS = 120; private const MAX_LOGIN_FAILURES = 5; private const LOGIN_WINDOW_SECONDS = 900; @@ -204,10 +208,19 @@ public function renderCustomer() { fn($reward) => (int)$reward['points_cost'] <= $balance )); + // confirmacao do lancamento que acabou de ser feito (task 5). so aparece logo depois de lancar: + // recarregar a pagina minutos depois nao mostra de novo nem oferece Desfazer. + $launchId = filter_var($_GET['lancamento'] ?? '', FILTER_VALIDATE_INT); + $launch = $launchId + ? (new PointsLogModel($this->db))->findFreshEarn($launchId, (int)$card['id'], self::UNDO_BANNER_SECONDS) + : false; + View::render('merchant/customer', [ - 'card' => $card, - 'rewards' => $rewards, - 'progress' => RewardProgress::next($balance, $activeRewards), + 'card' => $card, + 'rewards' => $rewards, + 'progress' => RewardProgress::next($balance, $activeRewards), + 'launch' => $launch ?: null, + 'undoSeconds' => self::UNDO_SECONDS, ]); } @@ -578,9 +591,10 @@ private function handleCustomer($merchantId) { redirect('merchant/customer', ['phone' => $phone, 'error' => 'pontos_invalidos']); } - $cardModel->addPoints($card['id'], $points, $description); + $logId = $cardModel->addPoints($card['id'], $points, $description); - redirect('merchant/customer', ['phone' => $phone, 'success' => 'pontos_lancados']); + // a tela do cliente mostra a confirmacao (novo saldo, quanto falta, Desfazer) a partir do lancamento + redirect('merchant/customer', ['phone' => $phone, 'lancamento' => $logId]); } private function logBelongsToCard(int $logId, int $cardId): bool { diff --git a/src/Models/PointsLogModel.php b/src/Models/PointsLogModel.php index bb26268..eab2ae9 100644 --- a/src/Models/PointsLogModel.php +++ b/src/Models/PointsLogModel.php @@ -47,6 +47,18 @@ public function pageByCard($cardId, int $limit, int $offset) { return $stmt->fetchAll(\PDO::FETCH_ASSOC); } + // lancamento (earn) deste cartao, feito ha no maximo $maxAgeSeconds e ainda nao estornado; false se nao houver + public function findFreshEarn($logId, $cardId, int $maxAgeSeconds) { + $sql = "SELECT id, quantity FROM points_log pl + WHERE pl.id = :id AND pl.card_id = :card_id AND pl.type = 'earn' + AND pl.created_at >= NOW() - INTERVAL " . (int)$maxAgeSeconds . " SECOND + AND NOT EXISTS (SELECT 1 FROM points_log r WHERE r.reverses_id = pl.id)"; + $stmt = $this->db->prepare($sql); + $stmt->execute([':id' => $logId, ':card_id' => $cardId]); + + return $stmt->fetch(\PDO::FETCH_ASSOC); + } + public function countByCard($cardId): int { $stmt = $this->db->prepare('SELECT COUNT(*) FROM points_log WHERE card_id = :card_id'); $stmt->execute([':card_id' => $cardId]); diff --git a/tests/Feature/BalcaoFlowTest.php b/tests/Feature/BalcaoFlowTest.php index 0e31644..7da6958 100644 --- a/tests/Feature/BalcaoFlowTest.php +++ b/tests/Feature/BalcaoFlowTest.php @@ -38,7 +38,7 @@ public function testFluxoCompletoDoCadastroDaLojaAoResgate(): void { // tela do cliente: lancar pontos e o saldo atualiza na hora $screen = 'merchant/customer&phone=11911110001'; [, $location] = $this->post('merchant/customer', ['action' => 'score', 'phone' => '11911110001', 'points' => 30], true, $screen); - $this->assertSame('merchant/customer&phone=11911110001&success=pontos_lancados', $location); + $this->assertMatchesRegularExpression('/^merchant\/customer&phone=11911110001&lancamento=\d+$/', $location); $this->get($screen); $this->assertStringContainsString('balance-value">30<', $this->lastBody); $this->assertStringContainsString('name="reward_id" value="' . $cafeId . '"', $this->lastBody, 'premio que o saldo paga aparece'); diff --git a/tests/Feature/LaunchConfirmationTest.php b/tests/Feature/LaunchConfirmationTest.php new file mode 100644 index 0000000..b143e3c --- /dev/null +++ b/tests/Feature/LaunchConfirmationTest.php @@ -0,0 +1,82 @@ +createMerchant('loja@teste.test'); + $this->createReward($merchant, 'Bolo', 100); + $card = $this->createCard($merchant, 'Bia Souza', '11922220002'); + if ($points) { + $this->db->exec("UPDATE loyalty_cards SET current_points = $points WHERE id = $card"); + } + $this->loginAs('loja@teste.test'); + return $card; + } + + private function launch(int $points): string { + [, $location] = $this->post('merchant/customer', ['action' => 'score', 'phone' => '11922220002', 'points' => $points], true, self::SCREEN); + return $location; + } + + public function testConfirmacaoMostraPontosNovoSaldoEQuantoFalta(): void { + $this->setUpStore(27); + + $location = $this->launch(30); + $this->assertMatchesRegularExpression('/^merchant\/customer&phone=11922220002&lancamento=\d+$/', $location); + + $this->get($location); + $this->assertMatchesRegularExpression('#\+30 pontos lançados\s*para Bia\.#', $this->lastBody); + $this->assertStringContainsString('Saldo agora: 57 pontos', $this->lastBody); + $this->assertStringContainsString('Faltam 43 para Bolo', $this->lastBody); + $this->assertStringContainsString('name="action" value="reverse"', $this->lastBody); + $this->assertStringContainsString('data-undo-seconds="30"', $this->lastBody); + } + + public function testDesfazerVoltaOSaldoEAConfirmacaoSome(): void { + $this->setUpStore(27); + $location = $this->launch(30); + $logId = (int)substr($location, strrpos($location, '=') + 1); + + [, $back] = $this->post('merchant/customer', ['action' => 'reverse', 'phone' => '11922220002', 'log_id' => $logId], true, $location); + $this->assertSame(self::SCREEN . '&success=lancamento_estornado', $back); + $this->assertSame(27, (int)$this->scalar('SELECT current_points FROM loyalty_cards')); + + // abrir de novo o link da confirmacao nao oferece desfazer de novo + $this->get($location); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + } + + public function testConfirmacaoSoAparecePraLancamentoRecenteDesteCliente(): void { + $card = $this->setUpStore(); + $location = $this->launch(10); + $logId = (int)substr($location, strrpos($location, '=') + 1); + + // passou do tempo do aviso + $this->db->exec("UPDATE points_log SET created_at = NOW() - INTERVAL 10 MINUTE WHERE id = $logId"); + $this->get($location); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + + // lancamento de outro cliente no link + $other = $this->createCard((int)$this->scalar('SELECT merchant_id FROM loyalty_cards WHERE id = ?', [$card]), 'Caio', '11933330003'); + $this->launchFor('11933330003'); + $otherLog = (int)$this->scalar('SELECT MAX(id) FROM points_log WHERE card_id = ?', [$other]); + $this->get(self::SCREEN . '&lancamento=' . $otherLog); + $this->assertStringNotContainsString('pontos lançados', $this->lastBody); + } + + private function launchFor(string $phone): void { + $this->post('merchant/customer', ['action' => 'score', 'phone' => $phone, 'points' => 5], true, 'merchant/customer&phone=' . $phone); + } + + public function testQuandoPagaTudoDizQueJaDaParaResgatar(): void { + $this->setUpStore(90); + $this->get($this->launch(20)); + $this->assertStringContainsString('Já dá para resgatar qualquer prêmio.', $this->lastBody); + } +} diff --git a/views/merchant/customer.php b/views/merchant/customer.php index 4a48902..b085e59 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -15,6 +15,29 @@ + + +
+

+ + + para . + Saldo agora: pontos. +

+ +

Faltam para .

+ +

Já dá para resgatar qualquer prêmio.

+ +
+ + + + + + +
+ +
DataTipoPontosDescrição
DataTipoPontosDescriçãoAções
- - Ganhou - - Resgatou + + + +
+ + + + + + +