diff --git a/CLAUDE.md b/CLAUDE.md index 1599541..4edd49a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ tsc # compila src/ts -> public/js (tsconfig.j php bin/migrate.php # roda as migrations pendentes (status | baseline NNN) php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR) php bin/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão) +php bin/expire-points.php # vence o saldo parado alem do prazo de cada loja (--dry-run so mostra) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -45,7 +46,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Nunca exibir o valor cru da query string. -**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem ``. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. +**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem ``. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`, `format_date`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. **Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL. @@ -67,7 +68,8 @@ 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`/`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. +- `points_log` é o histórico (`earn`/`redeem`/`reversal`/`expire`, `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. +- **Validade dos pontos (task 31)**: cada loja tem `merchants.points_expiry_months` (padrão 12, `NULL` = não vence, escolhido na tela Regra de pontos). O saldo inteiro vence depois desse tempo sem movimentação, contado de `loyalty_cards.last_use_at`; a data não é gravada, é sempre calculada. Quem vence é `LoyaltyCardModel::expire` / `expireIdle` (linha `expire` no `points_log`), chamado pelo `bin/expire-points.php`, que precisa estar agendado (`docs/operacao.md`). A data aparece para o cliente e para o lojista pelo `views/partials/points-expiry.php`, a partir do `expires_at` de `findByMerchantAndPhone`. - `merchants` guarda CPF **ou** CNPJ (normalizados por `DocumentValidator::normalize`, UNIQUE). O CNPJ pode ser alfanumérico (letras maiúsculas nas 12 primeiras posições, task 57). 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/bin/expire-points.php b/bin/expire-points.php new file mode 100644 index 0000000..cfa9b89 --- /dev/null +++ b/bin/expire-points.php @@ -0,0 +1,54 @@ +countExpirable(); + printf("venceriam agora: %d cartao(oes), %d ponto(s). nada foi alterado.\n", $preview['cards'], $preview['points']); + exit(0); + } + + $result = $cards->expireIdle(); + echo $result['cards'] === 0 + ? "nada a vencer.\n" + : sprintf("%d cartao(oes) com saldo vencido, %d ponto(s) no total.\n", $result['cards'], $result['points']); +} catch (Throwable $e) { + // vai pro terminal e, se houver LOG_FILE, pro log de erros (o agendador nem sempre guarda a saida) + fwrite(STDERR, 'erro ao vencer pontos: ' . $e->getMessage() . "\n"); + if ($logToFile) { + error_log('[expire-points] ' . $e->getMessage()); + } + exit(1); +} 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/docs/operacao.md b/docs/operacao.md index 19a656c..85a1b21 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -135,6 +135,34 @@ gunzip -c fidelityx-20261005-030000.sql.gz | docker compose exec -T db sh -c 'my A restauração **substitui** as tabelas do banco de destino pelo conteúdo do backup. Depois dela, rode `php bin/migrate.php` (o backup pode ser anterior à última migration). +## Vencimento dos pontos + +Cada loja tem um prazo de validade (`merchants.points_expiry_months`, padrão 12 meses; o lojista muda ou desliga na tela Regra de pontos). O saldo de um cliente vence quando ele fica esse tempo sem nenhuma movimentação naquela loja. Quem tira os pontos vencidos do saldo é este comando: + +```bash +php bin/expire-points.php # vence os saldos parados +php bin/expire-points.php --dry-run # só mostra quantos cartões e pontos venceriam, sem alterar nada +``` + +Cada saldo vencido vira uma linha `expire` no histórico (`points_log`), que aparece no extrato do cliente e nos relatórios da loja. Rodar de novo não vence nada em dobro. Lojas inativas e cartões anonimizados ficam de fora. Em caso de falha o comando sai com código 1. + +**Sem o agendamento nada vence**: a data aparece para o cliente, mas o saldo continua lá. Agende uma vez por dia, como o backup: + +``` +# cron no servidor (4h da manhã), com Docker +0 4 * * * cd /caminho/do/fidelityx && docker compose exec -T app php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1 + +# cron sem Docker +0 4 * * * cd /caminho/do/fidelityx && php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1 +``` + +No Windows (XAMPP), use o Agendador de Tarefas chamando `C:\xampp\php\php.exe bin\expire-points.php` na pasta do projeto. + +Cuidados: + +- O vencimento não tem "desfazer". Antes da primeira execução em um banco com dados reais, rode com `--dry-run` e faça um backup. +- Se a loja diminuir o prazo, os saldos que já passam do prazo novo vencem na próxima execução. + ## Registro de erros - Exceção não tratada e erro fatal viram uma linha no log e a página 500. A página mostra um código curto (ex.: `ab12cd34`) e a mesma sequência aparece na linha do log: quando um lojista relatar o erro, procure por ela. diff --git a/public/css/app.css b/public/css/app.css index 8319378..1b843e4 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -286,6 +286,12 @@ th.num { color: var(--warning-text); } +/* pontos vencidos por falta de movimentacao */ +.badge-expire { + background: var(--error-soft); + color: var(--error-text); +} + .btn-secondary { min-height: 44px; background: transparent; diff --git a/public/css/components.css b/public/css/components.css index 7dcd7fc..02cedc2 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -171,6 +171,13 @@ color: var(--warning-text); } +/* validade do saldo (partials/points-expiry.php): linha pequena embaixo dos pontos, + na consulta publica e na tela do cliente no painel */ +.points-expiry { + font-size: 0.85rem; + margin-top: 0.4rem; +} + /* acessibilidade (task 18) ------------------------------------------------ */ /* foco do teclado sempre visivel (inputs e selects ja tem o anel em :focus) */ 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/LoyaltyCardModel.php b/src/Models/LoyaltyCardModel.php index be5d0e6..7dee4fb 100644 --- a/src/Models/LoyaltyCardModel.php +++ b/src/Models/LoyaltyCardModel.php @@ -33,10 +33,16 @@ public function findOrCreate($merchantId, $customerId, ?string $customerName = n } // cartao do cliente (pelo telefone) nesta loja. cartao anonimizado nao tem mais telefone, entao nao aparece. + // expires_at: quando o saldo vence pela validade da loja (ultima movimentacao + prazo, a mesma conta + // do EXPIRABLE la embaixo). NULL quando nao ha o que vencer: sem saldo ou loja sem prazo. public function findByMerchantAndPhone($merchantId, $phone) { - $sql = 'SELECT lc.id, lc.current_points, lc.total_accumulated, lc.customer_name, lc.consent_at, lc.consent_version, c.phone + $sql = 'SELECT lc.id, lc.current_points, lc.total_accumulated, lc.customer_name, lc.consent_at, lc.consent_version, c.phone, + CASE WHEN lc.current_points > 0 AND m.points_expiry_months IS NOT NULL + THEN COALESCE(lc.last_use_at, lc.created_at) + INTERVAL m.points_expiry_months MONTH + END AS expires_at FROM loyalty_cards lc JOIN customers c ON c.id = lc.customer_id + JOIN merchants m ON m.id = lc.merchant_id WHERE lc.merchant_id = :merchant_id AND c.phone = :phone'; $stmt = $this->db->prepare($sql); $stmt->execute([ @@ -180,6 +186,90 @@ public function redeem($cardId, $reward) { } } + // validade dos pontos (task 31): saldo parado alem do prazo da loja (merchants.points_expiry_months). + // o prazo conta da ultima movimentacao do cartao (cartao sem nenhuma conta da criacao). ficam de fora: + // cartao sem saldo ou anonimizado, loja sem prazo (pontos nao vencem) e loja inativa (saldo congelado). + private const EXPIRABLE = "lc.current_points > 0 AND lc.anonymized_at IS NULL + AND m.status = 'active' AND m.points_expiry_months IS NOT NULL + AND COALESCE(lc.last_use_at, lc.created_at) <= NOW() - INTERVAL m.points_expiry_months MONTH"; + + // quantos cartoes e quantos pontos venceriam agora (so consulta, nao altera nada) + public function countExpirable(): array { + $row = $this->db->query( + 'SELECT COUNT(*) AS cards, COALESCE(SUM(lc.current_points), 0) AS points + FROM loyalty_cards lc JOIN merchants m ON m.id = lc.merchant_id + WHERE ' . self::EXPIRABLE + )->fetch(\PDO::FETCH_ASSOC); + + return ['cards' => (int)$row['cards'], 'points' => (int)$row['points']]; + } + + // vence o saldo de um cartao: zera os pontos e registra um 'expire' no historico (tudo ou nada). + // confere a regra de novo com o cartao travado: se o cliente movimentou (ou a loja mudou o prazo) + // entre a busca e este momento, nada vence. devolve quantos pontos venceram (0 = nada a vencer). + // o total acumulado e a data da ultima movimentacao ficam como estavam: vencer nao e movimentacao do cliente. + public function expire($cardId): int { + $this->db->beginTransaction(); + try { + // trava o cartao: lancamento ou resgate ao mesmo tempo espera o commit e ja ve o saldo certo + $stmt = $this->db->prepare('SELECT current_points FROM loyalty_cards WHERE id = :id FOR UPDATE'); + $stmt->execute([':id' => $cardId]); + $points = (int)$stmt->fetchColumn(); + + $stmt = $this->db->prepare( + 'SELECT m.points_expiry_months + FROM loyalty_cards lc JOIN merchants m ON m.id = lc.merchant_id + WHERE lc.id = :id AND ' . self::EXPIRABLE + ); + $stmt->execute([':id' => $cardId]); + $months = $stmt->fetchColumn(); + + if ($months === false) { + $this->db->rollBack(); + return 0; + } + + $this->db->prepare('UPDATE loyalty_cards SET current_points = 0 WHERE id = :id') + ->execute([':id' => $cardId]); + + $description = 'Pontos vencidos: ' . (int)$months . ' meses sem movimentação'; + $this->log($cardId, 'expire', $points, $description, null); + + $this->db->commit(); + return $points; + } catch (\Throwable $e) { + $this->db->rollBack(); + throw $e; + } + } + + // rotina do bin/expire-points.php: vence todos os saldos parados, um cartao por transacao + // (uma falha no meio nao desfaz os que ja venceram, e nenhuma trava fica aberta por muito tempo). + // devolve ['cards' => cartoes vencidos, 'points' => pontos vencidos]. rodar de novo nao vence nada em dobro. + public function expireIdle(int $batchSize = 200): array { + $total = ['cards' => 0, 'points' => 0]; + + do { + $ids = $this->db->query( + 'SELECT lc.id FROM loyalty_cards lc JOIN merchants m ON m.id = lc.merchant_id + WHERE ' . self::EXPIRABLE . ' ORDER BY lc.id LIMIT ' . (int)$batchSize + )->fetchAll(\PDO::FETCH_COLUMN); + + $expiredInBatch = 0; + foreach ($ids as $cardId) { + $points = $this->expire($cardId); + if ($points > 0) { + $expiredInBatch++; + $total['cards']++; + $total['points'] += $points; + } + } + // lote cheio = pode haver mais; lote sem nenhum vencimento = os que sobraram deixaram de valer, para + } while (count($ids) === $batchSize && $expiredInBatch > 0); + + return $total; + } + // filtro de busca por nome ou telefone. so numero (com ou sem mascara) busca no telefone; // qualquer outra coisa busca no nome. % e _ do que foi digitado valem como letra, nao como coringa. private function searchClause(string $search): array { 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/src/Support/helpers.php b/src/Support/helpers.php index 6443594..2db08b9 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -53,6 +53,11 @@ function format_datetime($value): string { return $value ? date('d/m/Y H:i', strtotime($value)) : '—'; } +// timestamp do banco -> "30/09/2026" (so o dia, ex.: vencimento dos pontos) +function format_date($value): string { + return $value ? date('d/m/Y', strtotime($value)) : '—'; +} + // monta a url de uma rota interna, pra usar em links e actions de form function url(string $route, array $params = []): string { return '/index.php?' . http_build_query(array_merge(['url' => $route], $params)); @@ -66,6 +71,7 @@ function log_type_view(string $type, ?string $reversedType = null): array { $type === 'redeem' => ['Resgatou', 'badge-redeem', '−'], $type === 'reversal' && $reversedType === 'redeem' => ['Estorno de resgate', 'badge-reversal', '+'], $type === 'reversal' => ['Estorno', 'badge-reversal', '−'], + $type === 'expire' => ['Venceu', 'badge-expire', '−'], default => [$type, '', ''], }; } 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('