DEV Community

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

Posted on

1 1 1

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

Do your career a big favor. Join DEV. (The website you're on right now)

It takes one minute, it's free, and is worth it for your career.

Get started

Community matters

Top comments (0)

Image of Timescale

Timescale – the developer's data platform for modern apps, built on PostgreSQL

Timescale Cloud is PostgreSQL optimized for speed, scale, and performance. Over 3 million IoT, AI, crypto, and dev tool apps are powered by Timescale. Try it free today! No credit card required.

Try free

đź‘‹ Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay