🇺🇸[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
-
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.
-
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.
-
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>
- 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>
- 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>
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;
}
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";
}
}
- 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";
}
}
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);
}
}
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
-
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.
-
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.
-
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>
- 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>
- 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>
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;
}
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";
}
}
- 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";
}
}
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);
}
}
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.
Top comments (0)