From 39bc8e5d8b2ee9bb12b710cb20481f46f3fbf640 Mon Sep 17 00:00:00 2001 From: andynnnfw Date: Thu, 8 Oct 2026 21:05:20 -0300 Subject: [PATCH 1/3] feat(db): prazo de validade dos pontos por loja (migration 011) - database/migrations/011_validade_pontos.sql: coluna points_expiry_months (padrao 12 meses) e tipo expire no points_log - database/schema.sql: mesma mudanca no estado final e registro da 011 em schema_migrations - docs/db/schema-explanation.md: documenta o prazo, o calculo do vencimento e o tipo expire - src/Models/MerchantModel.php: findById traz o prazo e updatePointsExpiry grava - src/Controllers/MerchantController.php: lista de prazos aceitos e acao de salvar a validade - views/merchant/points-rule.php: card Validade dos pontos com o select de prazo - views/partials/flash.php: mensagens validade_invalida e validade_salva, e o campo que o erro aponta - tests/Feature/PointsExpiryConfigTest.php: testes do padrao de 12 meses, troca, desligar, valor invalido e isolamento entre lojas --- database/migrations/011_validade_pontos.sql | 16 ++++ database/schema.sql | 12 ++- docs/db/schema-explanation.md | 6 +- src/Controllers/MerchantController.php | 25 +++++- src/Models/MerchantModel.php | 8 +- tests/Feature/PointsExpiryConfigTest.php | 93 +++++++++++++++++++++ views/merchant/points-rule.php | 31 +++++++ views/partials/flash.php | 3 + 8 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 database/migrations/011_validade_pontos.sql create mode 100644 tests/Feature/PointsExpiryConfigTest.php diff --git a/database/migrations/011_validade_pontos.sql b/database/migrations/011_validade_pontos.sql new file mode 100644 index 0000000..feef960 --- /dev/null +++ b/database/migrations/011_validade_pontos.sql @@ -0,0 +1,16 @@ +-- fidelityx - migration 011 (validade dos pontos, task 31) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/011_validade_pontos.sql) + +USE fidelityx; + +-- prazo de validade por loja: o saldo do cliente vence depois de points_expiry_months meses sem +-- nenhuma movimentacao naquela loja (loyalty_cards.last_use_at). NULL = os pontos nao vencem. +-- o padrao e 12 meses, e as lojas que ja existem tambem comecam com ele. +ALTER TABLE merchants + ADD COLUMN points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12 AFTER points_rule_cents; + +-- o vencimento vira uma linha propria no historico ('expire'), como o resgate e o estorno +ALTER TABLE points_log + MODIFY type ENUM('earn', 'redeem', 'reversal', 'expire') NOT NULL; diff --git a/database/schema.sql b/database/schema.sql index 20309b9..c0cd3f4 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -27,6 +27,9 @@ CREATE TABLE IF NOT EXISTS merchants ( -- regra de pontos pelo valor da compra: a cada points_rule_cents centavos, 1 ponto (arredonda pra baixo). -- NULL = sem regra (o lojista digita os pontos direto) points_rule_cents INT UNSIGNED NULL, + -- validade dos pontos: o saldo vence depois deste tanto de meses sem movimentacao do cliente na loja + -- (loyalty_cards.last_use_at). NULL = os pontos nao vencem + points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12, status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', -- para futuras implementações (NULL) @@ -82,8 +85,10 @@ CREATE TABLE IF NOT EXISTS loyalty_cards ( current_points INT NOT NULL DEFAULT 0, total_accumulated INT NOT NULL DEFAULT 0, - -- para futuras implementações (NULL) + -- ultima movimentacao (ganho, resgate ou estorno). e daqui que conta a validade dos pontos: + -- vencimento = last_use_at + merchants.points_expiry_months (calculado na hora, nao fica gravado) last_use_at TIMESTAMP NULL, + -- sem uso: a data de vencimento e calculada (acima), pra nao ficar errada quando a loja muda o prazo expiration_date TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -133,7 +138,7 @@ CREATE TABLE IF NOT EXISTS points_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_id BIGINT UNSIGNED NOT NULL, - type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn ou de um redeem + type ENUM('earn', 'redeem', 'reversal', 'expire') NOT NULL, -- reversal = estorno de um earn ou de um redeem, expire = saldo vencido quantity INT NOT NULL, description VARCHAR(255) NOT NULL, reward_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'redeem' @@ -267,4 +272,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('007_customers_so_telefone'), ('008_password_resets'), ('009_email_verifications'), - ('010_admins'); + ('010_admins'), + ('011_validade_pontos'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index 3b4a7d6..bf8d096 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -24,6 +24,7 @@ merchants 1───N loyalty_cards N───1 customers | Coluna | Observação | |---|---| | `points_rule_cents` | Regra de pontos pelo valor da compra: a cada tantos centavos, 1 ponto (sempre arredonda para baixo, `Money::pointsFor`). `NULL` = sem regra, o lojista digita os pontos. | +| `points_expiry_months` | Validade dos pontos (task 31): o saldo do cliente vence depois deste tanto de meses sem movimentação na loja, contados de `loyalty_cards.last_use_at`. Padrão 12; `NULL` = os pontos não vencem. O lojista escolhe na tela Regra de pontos, entre os prazos de `MerchantController::EXPIRY_MONTHS_OPTIONS`. | | `public_code` | `UNIQUE`, 8 caracteres. Código público da loja: vai no QR do cartaz e identifica a loja na consulta de saldo. Gerado no cadastro (`App\Support\PublicCode`). | | `email`, `cpf`, `cnpj` | `UNIQUE`. Só um dos dois documentos é preenchido, apenas com números. | | `password_hash` | BCRYPT (`password_hash`). | @@ -48,7 +49,8 @@ O cliente é **só o telefone** (task 11, ADR 002). As colunas `cpf`, `email`, ` | `anonymized_at` | Exclusão a pedido do cliente: o cartão perde nome, telefone, consentimento e saldo e fica só para os relatórios. | | `current_points` | Saldo disponível. Aumenta ao ganhar pontos e diminui ao resgatar. | | `total_accumulated` | Tudo que o cliente já ganhou nesta loja. Só aumenta. | -| `last_use_at` | Última movimentação. | +| `last_use_at` | Última movimentação (ganho, resgate ou estorno). É daqui que conta a validade dos pontos: vencimento = `last_use_at` + `merchants.points_expiry_months`. | +| `expiration_date` | Sem uso. A data de vencimento é calculada na hora (linha acima) em vez de gravada, para não ficar errada quando a loja muda o prazo. | ### `rewards` — catálogo de prêmios (novo no MVP) | Coluna | Observação | @@ -60,7 +62,7 @@ O cliente é **só o telefone** (task 11, ADR 002). As colunas `cpf`, `email`, ` ### `points_log` — histórico | Coluna | Observação | |---|---| -| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho ou de um resgate, task 44). `quantity` é sempre positivo: o sinal vem do tipo (o estorno de resgate devolve pontos). | +| `type` | `earn` (ganho), `redeem` (resgate), `reversal` (estorno de um ganho ou de um resgate, task 44) ou `expire` (saldo vencido por falta de movimentação, task 31). `quantity` é sempre positivo: o sinal vem do tipo (o estorno de resgate devolve pontos). | | `reward_id` | Preenchido em resgates. Vira `NULL` se o prêmio for apagado. | | `reverses_id` | Preenchido em estornos (`reversal`): o ganho ou resgate estornado. `UNIQUE`, então cada movimentação é estornada no máximo uma vez. | | `ip_address` | IP de quem fez a operação (o do cliente, mesmo atrás de proxy confiável: `TRUSTED_PROXIES`). | diff --git a/src/Controllers/MerchantController.php b/src/Controllers/MerchantController.php index 8ceb1a7..d83d6a6 100644 --- a/src/Controllers/MerchantController.php +++ b/src/Controllers/MerchantController.php @@ -54,6 +54,8 @@ class MerchantController { private const UNDO_BANNER_SECONDS = 120; private const RECENT_CUSTOMERS = 5; private const MAX_POINTS_RULE_CENTS = 100000000; + // prazos de validade dos pontos que a loja pode escolher, em meses sem movimentacao (task 31) + public const EXPIRY_MONTHS_OPTIONS = [3, 6, 12, 18, 24, 36]; private const MAX_LOGIN_FAILURES = 5; // mesmo e-mail + mesmo ip private const MAX_LOGIN_FAILURES_PER_IP = 20; // mesmo ip, somando todas as contas private const LOGIN_WINDOW_SECONDS = 900; @@ -244,12 +246,33 @@ public function renderPointsRule() { return; } - View::render('merchant/points-rule', ['ruleCents' => $this->pointsRule()]); + View::render('merchant/points-rule', [ + 'ruleCents' => $this->pointsRule(), + 'expiryMonths' => $this->pointsExpiryMonths(), + 'expiryOptions' => self::EXPIRY_MONTHS_OPTIONS, + ]); + } + + // validade dos pontos da loja logada, em meses sem movimentacao (null = nao vencem). so depois do authGuard. + private function pointsExpiryMonths(): ?int { + $months = $this->merchant['points_expiry_months'] ?? null; + return $months === null ? null : (int)$months; } private function handlePointsRule(int $merchantId) { Csrf::verify(); + // validade dos pontos (task 31): um dos prazos da lista, ou "never" pra desligar o vencimento + if (($_POST['action'] ?? '') === 'expiry') { + $choice = (string)($_POST['expiry_months'] ?? ''); + if ($choice !== 'never' && !in_array($choice, array_map('strval', self::EXPIRY_MONTHS_OPTIONS), true)) { + redirect('merchant/points-rule', ['error' => 'validade_invalida']); + } + + $this->merchantModel->updatePointsExpiry($merchantId, $choice === 'never' ? null : (int)$choice); + redirect('merchant/points-rule', ['success' => 'validade_salva']); + } + if (($_POST['action'] ?? '') === 'clear') { $this->merchantModel->updatePointsRule($merchantId, null); redirect('merchant/points-rule', ['success' => 'regra_removida']); diff --git a/src/Models/MerchantModel.php b/src/Models/MerchantModel.php index 04dfd15..f41d682 100644 --- a/src/Models/MerchantModel.php +++ b/src/Models/MerchantModel.php @@ -42,7 +42,7 @@ public function findByEmail($email){ // dados que o authGuard confere a cada requisicao (o hash da senha serve pra derrubar // as sessoes antigas quando a senha e trocada, ver SessionGuard::passwordSignature) public function findById($merchantId) { - $stmt = $this->db->prepare('SELECT id, owner_name, store_name, email, email_verified_at, status, points_rule_cents, password_hash FROM merchants WHERE id = :id'); + $stmt = $this->db->prepare('SELECT id, owner_name, store_name, email, email_verified_at, status, points_rule_cents, points_expiry_months, password_hash FROM merchants WHERE id = :id'); $stmt->execute([':id' => $merchantId]); return $stmt->fetch(\PDO::FETCH_ASSOC); @@ -54,6 +54,12 @@ public function updatePointsRule($merchantId, ?int $ruleCents): void { $stmt->execute([':rule' => $ruleCents, ':id' => $merchantId]); } + // validade dos pontos em meses sem movimentacao; null = os pontos da loja nao vencem + public function updatePointsExpiry($merchantId, ?int $months): void { + $stmt = $this->db->prepare('UPDATE merchants SET points_expiry_months = :months WHERE id = :id'); + $stmt->execute([':months' => $months, ':id' => $merchantId]); + } + // dados exibidos na tela de perfil; false se a conta nao existir public function findProfile($merchantId) { $stmt = $this->db->prepare( diff --git a/tests/Feature/PointsExpiryConfigTest.php b/tests/Feature/PointsExpiryConfigTest.php new file mode 100644 index 0000000..61d4654 --- /dev/null +++ b/tests/Feature/PointsExpiryConfigTest.php @@ -0,0 +1,93 @@ +scalar('SELECT points_expiry_months FROM merchants WHERE id = ?', [$merchant]); + } + + private function save(string $choice): ?string { + return $this->post(self::SCREEN, ['action' => 'expiry', 'expiry_months' => $choice], true, self::SCREEN)[1]; + } + + public function testLojaNovaComecaComDozeMeses(): void { + $merchant = $this->createMerchant('loja@teste.test'); + $this->loginAs('loja@teste.test'); + + $this->assertSame(12, (int)$this->months($merchant)); + $this->get(self::SCREEN); + $this->assertStringContainsString('Validade atual: 12 meses', $this->lastBody); + $this->assertStringContainsString('