Spring Boot backend for the Utility Billing System (WASAC / REG Rwanda): secure JWT authentication, customer and meter management, meter readings, versioned tariffs, bill generation, payment processing, PostgreSQL notification triggers, and professional email templates.
| Layer | Technology |
|---|---|
| Framework | Spring Boot 3.4.5 |
| Language | Java 22 |
| Security | Spring Security + JWT (jjwt 0.12.6) |
| Database | PostgreSQL |
| ORM | Spring Data JPA + Hibernate |
| Migrations | Flyway |
| Mapping | MapStruct 1.6.3 |
| Validation | Jakarta Bean Validation |
| Spring Mail (JavaMailSender) | |
| API Docs | SpringDoc OpenAPI (Swagger UI) |
| Utilities | Lombok |
- Java 22+
- Maven 3.9+
- PostgreSQL 12+
git clone https://github.com/orestengabo0/JavaT.git
cd JavaTCREATE DATABASE javat;application.properties (committed to git) contains only placeholder values. Put your real credentials in application-local.properties which is gitignored:
# src/main/resources/application-local.properties ← gitignored, never committed
spring.datasource.url=jdbc:postgresql://127.0.0.1:5432/javat
spring.datasource.username=your_postgres_username
spring.datasource.password=your_postgres_password
app.jwt.secret=your-base64-encoded-secret
spring.mail.username=your-email@gmail.com
spring.mail.password=your-16-char-app-password
app.mail.from=noreply@yourdomain.com
app.mail.from-name=YourAppName
app.mail.base-url=http://localhost:8080Then activate the local profile when running:
mvn spring-boot:run -Dspring-boot.run.profiles=localOr set the environment variable in your IDE run configuration:
SPRING_PROFILES_ACTIVE=local
Generating a JWT secret:
openssl rand -base64 32
mvn spring-boot:runFlyway runs automatically on startup and creates all tables. A default admin account is seeded.
http://localhost:8081/swagger-ui/index.html
User workflow & testing guide: docs/UBS_USER_WORKFLOW.md
ERD, architecture & flow diagrams: docs/UBS_SYSTEM_DESIGN.md
Database exports (pg_dump): exports/
API reference (Swagger + curl examples): docs/API_SWAGGER_TESTING.md
Seeded by Flyway migration V2. Use this to test admin endpoints immediately.
| Field | Value |
|---|---|
| Name | Emmanuel Uwimana |
admin@ubs.rw |
|
| Password | Admin@1234 |
| Role | ADMIN |
Change this password before deploying anywhere.
src/main/java/com/spring/JavaT/
├── auth/ # Authentication & authorization
│ ├── dto/ # Request/response DTOs
│ │ ├── RegisterRequest
│ │ ├── LoginRequest
│ │ ├── AuthResponse
│ │ ├── ForgotPasswordRequest
│ │ ├── ResetPasswordRequest
│ ├── AuthController # Public auth endpoints
│ ├── AuthService # Register, login, password reset, email verification
│ ├── AuthMapper # RegisterRequest → User (MapStruct)
│ ├── PasswordResetToken # Entity: time-limited password reset tokens
│ ├── PasswordResetTokenRepository
│ ├── EmailVerificationToken # Entity: time-limited email verification tokens
│ └── EmailVerificationTokenRepository
│
├── user/ # User management
│ ├── dto/
│ │ ├── UserDto # Safe read-only projection (no password)
│ │ ├── UpdateProfileRequest
│ │ ├── UpdatePasswordRequest
│ │ └── UpdateRoleRequest
│ ├── User # Entity (extends BaseEntity, implements UserDetails)
│ ├── Role # Enum: ADMIN, OPERATOR, FINANCE, CUSTOMER
│ ├── UserRepository # JpaRepository + JpaSpecificationExecutor
│ ├── UserService # Profile, password, role, deactivation
│ ├── UserController # /api/v1/users endpoints
│ └── UserMapper # User → UserDto (MapStruct)
│
├── customer/ # Utility customers (national ID, meters)
├── meter/ # Water and electricity meters
├── reading/ # Monthly meter readings
├── tariff/ # Versioned tariffs (flat / tiered)
├── bill/ # Bill generation and approval
├── payment/ # Partial and full payments
├── billing/ # Billing notifications (DB + API)
│
├── common/ # Shared infrastructure
│ ├── ApiResponse # Standard response envelope
│ ├── ApiError # Single error entry
│ ├── ResponseBuilder # Static factory for ResponseEntity
│ ├── BaseEntity # Abstract JPA base with audit fields
│ ├── EntityStatus # Enum: ACTIVE, INACTIVE, SUSPENDED, PENDING
│ ├── pagination/
│ │ ├── PaginationMeta # Extracted page metadata
│ │ ├── PageResponse # Generic paginated response
│ │ └── PaginationUtil # Defaults, caps, sort validation
│ ├── filter/
│ │ ├── SearchCriteria # Single filter condition (field, op, value)
│ │ └── BaseSpecification # Generic JPA Specification builder
│ └── validation/
│ ├── ValidationMessages # All validation message strings
│ ├── ValidationGroups # OnCreate, OnUpdate, OnPatch, OnDelete
│ ├── ValidPassword # Custom: password strength annotation
│ ├── ValidPasswordValidator
│ ├── NoWhitespace # Custom: no leading/trailing spaces
│ ├── NoWhitespaceValidator
│ ├── ValidEnum # Custom: string must match enum constant
│ └── ValidEnumValidator
│
├── security/ # Spring Security wiring
│ ├── JwtService # Token generation, validation, extraction
│ ├── JwtProperties # Typed config: app.jwt.*
│ ├── JwtAuthenticationFilter # Per-request JWT filter
│ ├── UserDetailsServiceImpl # Loads user by email
│ ├── SecurityEntryPoint # 401 handler → ApiResponse JSON
│ ├── AccessDeniedHandlerImpl # 403 handler → ApiResponse JSON
│ └── SecurityProperties # Typed config: app.security.*
│
├── notification/ # Email module
│ ├── EmailService # Async send, verification email, password reset email
│ ├── EmailRequest # Value object: to, subject, body, html flag
│ └── MailProperties # Typed config: app.mail.*
│
├── config/ # Spring configuration classes
│ ├── SecurityConfig # SecurityFilterChain, AuthenticationManager, BCrypt
│ ├── JpaConfig # @EnableJpaAuditing + AuditAwareImpl bean
│ ├── AsyncConfig # @EnableAsync + email thread pool
│ └── SwaggerConfig # OpenAPI definition + bearerAuth scheme
│
├── audit/
│ └── AuditAwareImpl # Resolves current principal for @CreatedBy/@LastModifiedBy
│
└── exception/ # Global exception handling
├── GlobalExceptionHandler # @RestControllerAdvice — all exception → ApiResponse
├── BusinessException # Base runtime exception with HttpStatus
├── ResourceNotFoundException # 404
├── DuplicateResourceException # 409
├── UnauthorizedException # 401
└── ForbiddenException # 403
src/main/resources/
├── application.properties
├── db/migration/
│ ├── V1__create_users_table.sql
│ ├── V2__seed_admin_user.sql
│ ├── V3__create_password_reset_tokens_table.sql
│ ├── V4__create_email_verification_tokens_table.sql
│ └── V5__update_admin_password.sql
└── templates/email/
├── verification.html
└── password-reset.html
All responses follow this envelope:
{
"success": true,
"message": "Operation successful",
"data": {},
"errors": null,
"timestamp": "2024-01-01T00:00:00Z",
"path": "/api/v1/users"
}Error responses populate errors instead of data:
{
"success": false,
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "must be a valid email address", "code": "Email" }
]
}| Method | Path | Description |
|---|---|---|
POST |
/register |
Register a new account. Sends a verification email. Returns tokens. |
POST |
/login |
Login with email + password. Returns tokens. Blocked if email unverified. |
GET |
/verify-email?token= |
Verify email address from the link in the verification email. |
POST |
/resend-verification |
Resend the verification email. |
POST |
/forgot-password |
Request a password reset email. Always returns 200. |
POST |
/reset-password |
Complete password reset using the token from email. |
Register request:
{
"firstName": "Marie Claire",
"lastName": "Ingabire",
"username": "mclaire",
"email": "marie.ingabire@example.rw",
"password": "Secret@123"
}Login request:
{
"email": "marie.ingabire@example.rw",
"password": "Secret@123"
}Auth response:
{
"accessToken": "eyJhbGci...",
"refreshToken": "eyJhbGci...",
"tokenType": "Bearer",
"expiresIn": 86400,
"email": "marie.ingabire@example.rw",
"role": "USER"
}| Method | Path | Role | Description |
|---|---|---|---|
GET |
/me |
Any | Get own profile |
PATCH |
/me |
Any | Update own name / username |
PATCH |
/me/password |
Any | Change own password |
POST |
/ |
ADMIN | Create a user (ACTIVE immediately) |
GET |
/ |
ADMIN | Read — list users (paginated + filtered) |
GET |
/{id} |
ADMIN | Read — get user by ID |
PATCH |
/{id} |
ADMIN | Update profile (name, username) |
PATCH |
/{id}/role |
ADMIN | Update role |
DELETE |
/{id} |
ADMIN | Delete user (soft-delete) |
PATCH |
/{id}/deactivate |
ADMIN | Soft-deactivate (same as delete) |
PATCH |
/{id}/activate |
ADMIN | Restore a deactivated user |
Authenticated requests — add the header:
Authorization: Bearer <accessToken>
List users with filtering:
GET /api/v1/users?page=0&size=10&sortBy=createdAt&sortDir=desc&role=USER&status=ACTIVE&search=gmail
All filter params are optional. search does a partial match on email.
Every endpoint returns the same JSON shape. data is null on errors; errors is null on success. Both are omitted from JSON when null (@JsonInclude(NON_NULL)).
Use ResponseBuilder in controllers:
return ResponseBuilder.ok(dto, "User retrieved", request);
return ResponseBuilder.created(dto, "Account created", request);
return ResponseBuilder.noContent();- Tokens are signed with HMAC-SHA256 using a Base64-encoded secret.
- Access token default: 24 hours. Refresh token default: 7 days.
- The
subclaim is the user's email (not username). - A custom
roleclaim is embedded so role checks don't require a DB lookup. - The
JwtAuthenticationFilterruns before every request, extracts the token, validates it, and populatesSecurityContextHolder.
- User registers → status set to
PENDING→ verification email sent async. - User clicks link →
GET /api/v1/auth/verify-email?token=...→ status set toACTIVE. - Login with
PENDINGstatus →403with codeEMAIL_NOT_VERIFIED. - Token expires after 24 hours → user can request a new one via
/resend-verification.
POST /forgot-password→ token created (15 min expiry) → email sent async.POST /reset-passwordwith token + new password → password updated, token marked used.- Always returns 200 on
/forgot-passwordregardless of whether the email exists (prevents enumeration).
Every entity that extends BaseEntity automatically gets:
| Column | Type | Description |
|---|---|---|
id |
BIGINT |
Auto-generated primary key |
created_at |
TIMESTAMPTZ |
Set on INSERT, never updated |
updated_at |
TIMESTAMPTZ |
Updated on every UPDATE |
created_by |
VARCHAR(100) |
Principal who created the record |
updated_by |
VARCHAR(100) |
Principal who last modified it |
deleted |
BOOLEAN |
Soft-delete flag |
deleted_at |
TIMESTAMPTZ |
When it was soft-deleted |
deleted_by |
VARCHAR(100) |
Who soft-deleted it |
status |
VARCHAR(20) |
ACTIVE, INACTIVE, SUSPENDED, PENDING |
Soft-delete a record:
entity.softDelete(currentUserEmail);
repository.save(entity);Restore it:
entity.restore();
repository.save(entity);Custom annotations in common/validation/:
| Annotation | What it validates |
|---|---|
@ValidPassword |
8–72 chars, upper + lower + digit + special char |
@NoWhitespace |
No leading or trailing spaces |
@ValidEnum |
String matches a given enum's constants |
All validation messages are constants in ValidationMessages — change once, applies everywhere.
Use ValidationGroups to apply different rules per operation:
// Controller — only OnCreate constraints fire
@Validated(ValidationGroups.OnCreate.class) @RequestBody RegisterRequest body
// Controller — only OnPatch constraints fire
@Validated(ValidationGroups.OnPatch.class) @RequestBody UpdateProfileRequest bodyPaginated response shape:
{
"content": [...],
"meta": {
"page": 0,
"size": 10,
"totalElements": 47,
"totalPages": 5,
"first": true,
"last": false,
"empty": false
}
}In a service:
Specification<User> spec = new BaseSpecification<>(criteria);
Page<User> page = userRepository.findAll(spec, pageable);
return PageResponse.of(page, userMapper::toDto);In a controller:
Pageable pageable = PaginationUtil.toPageable(page, size, sortBy, sortDir, ALLOWED_SORT_FIELDS);Defaults: page=0, size=10, sortBy=id, sortDir=asc. Max size: 100.
Adding a filter to any entity:
- Make the repository extend
JpaSpecificationExecutor<YourEntity>. - Build a
List<SearchCriteria>from request params. - Pass to
new BaseSpecification<>(criteria).
All emails are sent asynchronously on a dedicated thread pool (emailTaskExecutor). The calling thread returns immediately.
HTML templates live in src/main/resources/templates/email/. Variables use {{placeholder}} syntax — no template engine needed.
Sending a custom email:
emailService.send(EmailRequest.builder()
.to("user@example.com")
.subject("Welcome")
.body("<h1>Hello!</h1>")
.html(true)
.build());Adding a new email type:
- Create
templates/email/your-template.htmlwith{{placeholder}}variables. - Add a method to
EmailServiceannotated with@Async("emailTaskExecutor"). - Call
loadTemplate("your-template.html", Map.of(...))and pass tosend().
Local development without a real mail server — use Mailpit:
spring.mail.host=localhost
spring.mail.port=1025
spring.mail.properties.mail.smtp.auth=false
spring.mail.properties.mail.smtp.starttls.enable=falseOpen http://localhost:8025 to see all sent emails.
GlobalExceptionHandler catches everything and returns a consistent ApiResponse. You never need to write try/catch in controllers or services for these cases:
| Exception | HTTP |
|---|---|
MethodArgumentNotValidException |
400 — validation errors with field details |
ConstraintViolationException |
400 — path/query param violations |
ResourceNotFoundException |
404 |
DuplicateResourceException |
409 |
UnauthorizedException |
401 |
ForbiddenException |
403 |
DataIntegrityViolationException |
409 — DB unique constraint (parses field name from PostgreSQL/MySQL error) |
TransactionSystemException |
400 — entity-level validation at commit time |
AuthenticationException |
401 |
AccessDeniedException |
403 |
HttpMessageNotReadableException |
400 — malformed JSON |
NoResourceFoundException |
404 — unknown URL |
Exception |
500 — catch-all, real cause logged server-side only |
Throwing domain errors in services:
throw new ResourceNotFoundException("User", "id", id);
throw new DuplicateResourceException("User", "email", email);
throw new ForbiddenException("You can only modify your own resources");
throw new BusinessException("Account is suspended", HttpStatus.FORBIDDEN);Migrations live in src/main/resources/db/migration/ and follow the naming convention V{version}__{description}.sql.
| File | What it does |
|---|---|
V1 |
Creates the users table |
V2 |
Seeds the default admin user |
V3 |
Creates password_reset_tokens table |
V4 |
Creates email_verification_tokens table |
V5 |
Updates admin password hash |
Adding a new migration:
- Create
V6__your_description.sqlindb/migration/. - Write your SQL.
- Restart the app — Flyway runs it automatically.
Never modify an existing migration file after it has been applied. Flyway checksums every file and will refuse to start if a checksum changes.
Three roles: USER, MODERATOR, ADMIN.
Coarse-grained — on controller methods:
@PreAuthorize("hasRole('ADMIN')")
@GetMapping
public ResponseEntity<?> listAll(...) { ... }Fine-grained — inside service methods:
if (!currentUser.getId().equals(resourceOwnerId)) {
throw new ForbiddenException("You can only modify your own resources");
}@EnableMethodSecurity is active in SecurityConfig, so @PreAuthorize works on any Spring-managed bean.
All custom properties are prefixed with app.*:
# JWT
app.jwt.secret= # Base64-encoded HMAC-SHA256 key (min 32 bytes)
app.jwt.expiration-ms=86400000 # Access token TTL in ms (default: 24h)
app.jwt.refresh-expiration-ms=604800000 # Refresh token TTL in ms (default: 7d)
app.jwt.issuer=UBS # JWT iss claim
# Security
app.security.public-paths[0]=/api/v1/auth/** # Paths that bypass JWT
# Mail
app.mail.from=noreply@yourdomain.com
app.mail.from-name=YourAppName
app.mail.base-url=http://localhost:8080 # Used to build links in emails
# Async thread pool
app.async.core-pool-size=2
app.async.max-pool-size=5
app.async.queue-capacity=100
app.async.thread-name-prefix=async-email-
# Token expiry
app.auth.password-reset-token-expiry-minutes=15
app.auth.verification-token-expiry-hours=24- Clone the repository.
- Rename the package from
com.spring.JavaTtocom.yourcompany.yourapp(IDE refactor → rename package). - Update
spring.application.nameinapplication.properties. - Update
pom.xml<groupId>,<artifactId>, and<name>. - Create your database and update the datasource credentials.
- Generate a new JWT secret:
openssl rand -base64 32. - Configure your SMTP credentials.
- Run
mvn spring-boot:run— Flyway creates the schema automatically. - Start building your domain features on top.
@Entity
@Table(name = "products")
public class Product extends BaseEntity {
// Your fields — id, timestamps, audit, soft-delete, status come from BaseEntity
private String name;
private BigDecimal price;
}// Repository
public interface ProductRepository extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> { }
// Service
public PageResponse<ProductDto> findAll(List<SearchCriteria> criteria, Pageable pageable) {
Specification<Product> spec = new BaseSpecification<>(criteria);
return PageResponse.of(productRepository.findAll(spec, pageable), productMapper::toDto);
}
// Controller
@GetMapping
public ResponseEntity<ApiResponse<PageResponse<ProductDto>>> list(
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir,
@RequestParam(required = false) String name,
HttpServletRequest request) {
Pageable pageable = PaginationUtil.toPageable(page, size, sortBy, sortDir,
Set.of("id", "name", "price", "createdAt"));
List<SearchCriteria> criteria = new ArrayList<>();
if (name != null) criteria.add(new SearchCriteria("name", SearchCriteria.Op.LIKE, name));
return ResponseBuilder.ok(
productService.findAll(criteria, pageable),
"Products retrieved successfully",
request);
}// 1. Create templates/email/welcome.html with {{firstName}}, {{appName}} placeholders
// 2. Add to EmailService
@Async("emailTaskExecutor")
public void sendWelcomeEmail(String toEmail, String firstName) {
String body = loadTemplate("welcome.html", Map.of(
"appName", mailProperties.getFromName(),
"firstName", firstName
));
send(EmailRequest.builder()
.to(toEmail)
.subject("Welcome to " + mailProperties.getFromName())
.body(body)
.html(true)
.build());
}With the app running on port 8081 and PostgreSQL available:
# Full UBS suite (auth, users, customers, meters, bills, payments, triggers)
.\scripts\test-all-endpoints.ps1
# Quick smoke test
.\scripts\test-api.ps1Admin credentials: admin@ubs.rw / Admin@1234
MIT — use freely for personal and commercial projects.