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)