DEV Community

Cover image for Building a Simple Voucher System for Small Businesses
Matheus Bernardes Spilari
Matheus Bernardes Spilari

Posted on

Building a Simple Voucher System for Small Businesses

🇺🇸[EN-US] Building a Simple Voucher System for Small Businesses

When I started as a freelancer, one of my first projects was for a small burger shop. The owner wanted a voucher system to reward loyal customers: after collecting five vouchers, customers could claim a free burger. The project needed to be simple, reliable, and tailored to their specific needs. Here's how I approached it.

The Challenge

The main requirements were:

  • Generate unique vouchers for customers when they purchase a burger.
  • Validate a set of five vouchers to allow for a free burger.
  • Keep the system lightweight, as it would run on a single machine.

My Solution

I designed the system using Spring Boot with Thymeleaf to render the front end. Instead of building a complex REST API, I created an intuitive web interface that allows employees to generate and validate vouchers directly.

Key Features

  1. Voucher Generation:

    • A unique token is generated based on the current date and time.
    • The token is stored in a Redis database (for scalability) or in memory (for simplicity).
    • A web page with a single button generates a new token.
  2. Voucher Validation:

    • Employees can input five tokens into a form to verify their validity.
    • If all tokens are valid, the system approves the free burger.
  3. Simplicity:

    • Using Thymeleaf, I avoided the need for a separate frontend framework.
    • The system is accessible via any browser and integrates seamlessly with the small business's operations.

Technical Stack

  • Backend: Spring Boot
  • Frontend: Thymeleaf
  • Database: Redis (for token storage and expiration)
  • Hosting: A single machine

Code

HTML Templates

Inside the folder resources > templates.
Create 3 files, these are the views of our application.

  • index.html - The home page
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Gerenciador de Vouchers</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Bem-vindo ao Sistema de Vouchers</h1> <ul> <li><a th:href="@{/vouchers/create}">Gerar Voucher</a></li> <li><a th:href="@{/vouchers/validate}">Validar Vouchers</a></li> </ul> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode
  • createToken.html - View to create tokens
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Gerar Token</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Gerar Voucher</h1> <a href="/">Página Inicial</a> <form action="/vouchers/create" method="post"> <button type="submit">Gerar Voucher</button> </form> <div class="ticket" th:if="${token}"> <p>Seu Voucher:</p> <h2 th:text="${token}"></h2> <p>Valido até:</p> <h3 th:text="${validade}"></h3> </div> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode
  • validateTokens.html - View to validate tokens
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Validar Vouchers</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Validar Vouchers</h1> <a href="/">Página Inicial</a> <p class="errors" th:if="${erros}" th:text="${erros}"></p> <form action="/vouchers/validate" method="post"> <label for="token1">Token 1:</label> <input type="text" id="token1" name="token1" required> <label for="token2">Token 2:</label> <input type="text" id="token2" name="token2" required> <label for="token3">Token 3:</label> <input type="text" id="token3" name="token3" required> <label for="token4">Token 4:</label> <input type="text" id="token4" name="token4" required> <label for="token5">Token 5:</label> <input type="text" id="token5" name="token5" required> <button type="submit">Validar</button> </form> <p th:if="${message}" th:text="${message}"></p> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode

CSS

Inside the folder resources > static
Create a folder CSS and inside that a file called style.css

style.css

body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f9f9f9; color: #333; } .container { max-width: 600px; margin: 50px auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); } h1 { color: #0073e6; text-align: center; } a { text-decoration: none; color: #0073e6; margin-bottom: 20px; display: inline-block; } a:hover { text-decoration: underline; } button { background-color: #0073e6; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; } button:hover { background-color: #005bb5; } form { display: flex; flex-direction: column; } label { margin: 10px 0 5px; } input { padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 15px; } p { margin-top: 20px; font-weight: bold; color: #4caf50; } .errors{ color: red; } .ticket { margin-top: 20px; padding: 20px; border: 2px dashed #333; border-radius: 10px; background: linear-gradient(135deg, #fdfdfd 25%, #f3f3f3 100%); text-align: center; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); position: relative; } .ticket p { font-size: 1.2em; font-weight: bold; margin: 0; color: #555; } .ticket h2 { font-size: 2em; margin: 10px 0 0; color: #000; font-family: 'Courier New', Courier, monospace; } .ticket::before, .ticket::after { content: ''; position: absolute; width: 20px; height: 20px; background: #f9f9f9; border: 2px solid #333; border-radius: 50%; top: 50%; transform: translateY(-50%); z-index: 10; } .ticket::before { left: -10px; } .ticket::after { right: -10px; } 
Enter fullscreen mode Exit fullscreen mode

Controllers

Closer to the main function, create a folder called controllers, inside that we are going to create two controllers:

  • ViewsController.java

This controller will show the views of our application. REMEMBER, the return of every function has to be the same name of the respective HTML file.

package dev.mspilari.voucher_api.controllers; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class ViewsController { @GetMapping("/") public String seeHomePage() { return "index"; } @GetMapping("/vouchers/create") public String createTokenPage() { return "createToken"; } @GetMapping("/vouchers/validate") public String verifyTokenPage() { return "validateTokens"; } } 
Enter fullscreen mode Exit fullscreen mode
  • TokenController.java
package dev.mspilari.voucher_api.controllers; import java.util.Map; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import dev.mspilari.voucher_api.dto.TokenDto; import dev.mspilari.voucher_api.services.TokenService; import jakarta.validation.Valid; @Controller public class TokenController { private TokenService tokenService; public TokenController(TokenService tokenService) { this.tokenService = tokenService; } @PostMapping("/vouchers/create") public String createToken(Model model) { Map<String, String> response = tokenService.generateAndSaveToken(); model.addAllAttributes(response); return "createToken"; } @PostMapping("/vouchers/validate") public String validateTokens(@Valid @ModelAttribute TokenDto tokens, Model model) { Map<String, String> response = tokenService.verifyTokens(tokens); model.addAllAttributes(response); return "validateTokens"; } } 
Enter fullscreen mode Exit fullscreen mode

Services

Inside the services folder create:

  • TokenService.java
package dev.mspilari.voucher_api.services; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import dev.mspilari.voucher_api.dto.TokenDto; @Service public class TokenService { @Value("${expiration_time:60}") private String timeExpirationInSeconds; private RedisTemplate<String, String> redisTemplate; public TokenService(RedisTemplate<String, String> template) { this.redisTemplate = template; } public Map<String, String> generateAndSaveToken() { String token = generateUuidToken(); Long timeExpiration = parseStringToLong(timeExpirationInSeconds); String validity = formatExpirationDate(); var response = new HashMap<String, String>(); redisTemplate.opsForValue().set(token, "Válido até: " + validity, timeExpiration, TimeUnit.SECONDS); response.put("token", token); response.put("validade", validity); return response; } private String generateUuidToken() { return UUID.randomUUID().toString(); } private Long parseStringToLong(String value) { try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid value for expiration time: " + value, e); } } private String formatExpirationDate() { Instant now = Instant.now(); ZonedDateTime expirationDate = ZonedDateTime.ofInstant( now.plusSeconds(parseStringToLong(timeExpirationInSeconds)), ZoneId.systemDefault()); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); return expirationDate.format(formatter); } public Map<String, String> verifyTokens(TokenDto tokens) { var response = new HashMap<String, String>(); List<String> tokensList = tokenDto2List(tokens); if (!areTokensUnique(tokens)) { response.put("erros", "Os tokens não podem ser iguais"); return response; } if (tokensExist(tokensList)) { response.put("erros", "Tokens informados são inválidos."); return response; } redisTemplate.delete(tokensList); response.put("message", "Os tokens são válidos"); return response; } private boolean areTokensUnique(TokenDto tokens) { List<String> tokensList = tokenDto2List(tokens); return new HashSet<>(tokensList).size() == tokensList.size(); } private List<String> tokenDto2List(TokenDto tokens) { return List.of(tokens.token1(), tokens.token2(), tokens.token3(), tokens.token4(), tokens.token5()); } private boolean tokensExist(List<String> tokensList) { return redisTemplate.opsForValue().multiGet(tokensList).contains(null); } } 
Enter fullscreen mode Exit fullscreen mode

This approach keeps the project simple yet scalable for future needs.

If you’re interested in implementing a similar solution, feel free to reach out or check the full source code here.


🇧🇷[PT-BR] Construindo um Sistema Simples de Vouchers para Pequenos Negócios

Quando comecei como freelancer, um dos meus primeiros projetos foi para uma pequena hamburgueria. O dono queria um sistema de vouchers para recompensar clientes fiéis: após coletar cinco vouchers, os clientes poderiam ganhar um lanche grátis. O projeto precisava ser simples, confiável e adaptado às necessidades específicas. Veja como eu desenvolvi essa ideia.

O Desafio

Os principais requisitos eram:

  • Gerar vouchers únicos para os clientes ao comprarem um lanche.
  • Validar um conjunto de cinco vouchers para liberar um lanche grátis.
  • Manter o sistema leve, já que rodaria em uma única máquina.

Minha Solução

Eu projetei o sistema usando Spring Boot com Thymeleaf para renderizar o front-end. Em vez de construir uma API REST complexa, criei uma interface web intuitiva que permite aos funcionários gerarem e validarem vouchers diretamente.

Funcionalidades Principais

  1. Geração de Vouchers:

    • Um token único é gerado com base na data e hora atual.
    • O token é armazenado em um banco de dados Redis (para escalabilidade) ou em memória (para simplicidade).
    • Uma página web com um botão único gera o novo token.
  2. Validação de Vouchers:

    • Os funcionários podem inserir cinco tokens em um formulário para verificar sua validade.
    • Se todos os tokens forem válidos, o sistema aprova o lanche grátis.
  3. Simplicidade:

    • Usando o Thymeleaf, eliminei a necessidade de um framework de front-end separado.
    • O sistema é acessível por qualquer navegador e se integra facilmente às operações da hamburgueria.

Tecnologias Utilizadas

  • Backend: Spring Boot
  • Frontend: Thymeleaf
  • Banco de Dados: Redis (para armazenar os tokens e gerenciar expiração)
  • Hospedagem: Uma máquina local

Code

HTML Templates

Dentro do diretório resources > templates.
Crie 3 arquivos que serão as views da nossa aplicação.

  • index.html - A página inicial
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Gerenciador de Vouchers</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Bem-vindo ao Sistema de Vouchers</h1> <ul> <li><a th:href="@{/vouchers/create}">Gerar Voucher</a></li> <li><a th:href="@{/vouchers/validate}">Validar Vouchers</a></li> </ul> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode
  • createToken.html - Página de criação de tokens
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Gerar Token</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Gerar Voucher</h1> <a href="/">Página Inicial</a> <form action="/vouchers/create" method="post"> <button type="submit">Gerar Voucher</button> </form> <div class="ticket" th:if="${token}"> <p>Seu Voucher:</p> <h2 th:text="${token}"></h2> <p>Valido até:</p> <h3 th:text="${validade}"></h3> </div> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode
  • validateTokens.html - Página de validação de tokens
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Validar Vouchers</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <div class="container"> <h1>Validar Vouchers</h1> <a href="/">Página Inicial</a> <p class="errors" th:if="${erros}" th:text="${erros}"></p> <form action="/vouchers/validate" method="post"> <label for="token1">Token 1:</label> <input type="text" id="token1" name="token1" required> <label for="token2">Token 2:</label> <input type="text" id="token2" name="token2" required> <label for="token3">Token 3:</label> <input type="text" id="token3" name="token3" required> <label for="token4">Token 4:</label> <input type="text" id="token4" name="token4" required> <label for="token5">Token 5:</label> <input type="text" id="token5" name="token5" required> <button type="submit">Validar</button> </form> <p th:if="${message}" th:text="${message}"></p> </div> </body> </html> 
Enter fullscreen mode Exit fullscreen mode

CSS

Dentro do diretório resources > static .
Crie um diretório chamado CSS e dentro dele um arquivo style.css.

style.css

body { font-family: Arial, sans-serif; margin: 0; padding: 0; background-color: #f9f9f9; color: #333; } .container { max-width: 600px; margin: 50px auto; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); } h1 { color: #0073e6; text-align: center; } a { text-decoration: none; color: #0073e6; margin-bottom: 20px; display: inline-block; } a:hover { text-decoration: underline; } button { background-color: #0073e6; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; } button:hover { background-color: #005bb5; } form { display: flex; flex-direction: column; } label { margin: 10px 0 5px; } input { padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 15px; } p { margin-top: 20px; font-weight: bold; color: #4caf50; } .errors{ color: red; } .ticket { margin-top: 20px; padding: 20px; border: 2px dashed #333; border-radius: 10px; background: linear-gradient(135deg, #fdfdfd 25%, #f3f3f3 100%); text-align: center; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2); position: relative; } .ticket p { font-size: 1.2em; font-weight: bold; margin: 0; color: #555; } .ticket h2 { font-size: 2em; margin: 10px 0 0; color: #000; font-family: 'Courier New', Courier, monospace; } .ticket::before, .ticket::after { content: ''; position: absolute; width: 20px; height: 20px; background: #f9f9f9; border: 2px solid #333; border-radius: 50%; top: 50%; transform: translateY(-50%); z-index: 10; } .ticket::before { left: -10px; } .ticket::after { right: -10px; } 
Enter fullscreen mode Exit fullscreen mode

Controllers

Próximo da função principal, crie um diretório chamado controllers, dentro dele criaremos dois controllers:

  • ViewsController.java

Esse controller mostrará as views da nossa aplicação. LEMBRE-SE, cada método deve retornar o mesmo nome do arquivo HTML.

package dev.mspilari.voucher_api.controllers; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class ViewsController { @GetMapping("/") public String seeHomePage() { return "index"; } @GetMapping("/vouchers/create") public String createTokenPage() { return "createToken"; } @GetMapping("/vouchers/validate") public String verifyTokenPage() { return "validateTokens"; } } 
Enter fullscreen mode Exit fullscreen mode
  • TokenController.java
package dev.mspilari.voucher_api.controllers; import java.util.Map; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import dev.mspilari.voucher_api.dto.TokenDto; import dev.mspilari.voucher_api.services.TokenService; import jakarta.validation.Valid; @Controller public class TokenController { private TokenService tokenService; public TokenController(TokenService tokenService) { this.tokenService = tokenService; } @PostMapping("/vouchers/create") public String createToken(Model model) { Map<String, String> response = tokenService.generateAndSaveToken(); model.addAllAttributes(response); return "createToken"; } @PostMapping("/vouchers/validate") public String validateTokens(@Valid @ModelAttribute TokenDto tokens, Model model) { Map<String, String> response = tokenService.verifyTokens(tokens); model.addAllAttributes(response); return "validateTokens"; } } 
Enter fullscreen mode Exit fullscreen mode

Services

Dentro do diretório services, crie:

  • TokenService.java
package dev.mspilari.voucher_api.services; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.TimeUnit; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import dev.mspilari.voucher_api.dto.TokenDto; @Service public class TokenService { @Value("${expiration_time:60}") private String timeExpirationInSeconds; private RedisTemplate<String, String> redisTemplate; public TokenService(RedisTemplate<String, String> template) { this.redisTemplate = template; } public Map<String, String> generateAndSaveToken() { String token = generateUuidToken(); Long timeExpiration = parseStringToLong(timeExpirationInSeconds); String validity = formatExpirationDate(); var response = new HashMap<String, String>(); redisTemplate.opsForValue().set(token, "Válido até: " + validity, timeExpiration, TimeUnit.SECONDS); response.put("token", token); response.put("validade", validity); return response; } private String generateUuidToken() { return UUID.randomUUID().toString(); } private Long parseStringToLong(String value) { try { return Long.parseLong(value); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid value for expiration time: " + value, e); } } private String formatExpirationDate() { Instant now = Instant.now(); ZonedDateTime expirationDate = ZonedDateTime.ofInstant( now.plusSeconds(parseStringToLong(timeExpirationInSeconds)), ZoneId.systemDefault()); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); return expirationDate.format(formatter); } public Map<String, String> verifyTokens(TokenDto tokens) { var response = new HashMap<String, String>(); List<String> tokensList = tokenDto2List(tokens); if (!areTokensUnique(tokens)) { response.put("erros", "Os tokens não podem ser iguais"); return response; } if (tokensExist(tokensList)) { response.put("erros", "Tokens informados são inválidos."); return response; } redisTemplate.delete(tokensList); response.put("message", "Os tokens são válidos"); return response; } private boolean areTokensUnique(TokenDto tokens) { List<String> tokensList = tokenDto2List(tokens); return new HashSet<>(tokensList).size() == tokensList.size(); } private List<String> tokenDto2List(TokenDto tokens) { return List.of(tokens.token1(), tokens.token2(), tokens.token3(), tokens.token4(), tokens.token5()); } private boolean tokensExist(List<String> tokensList) { return redisTemplate.opsForValue().multiGet(tokensList).contains(null); } } 
Enter fullscreen mode Exit fullscreen mode

Essa abordagem mantém o projeto simples, mas escalável para necessidades futuras.

Se você se interessou em implementar uma solução parecida, entre em contato comigo ou confira o código-fonte completo aqui.


📍 Reference

💻 Project Repository

👋 Talk to me

Top comments (0)