Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Repository files navigation

UBS — Utility Billing System

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.


Tech Stack

LayerTechnology
FrameworkSpring Boot 3.4.5
LanguageJava 22
SecuritySpring Security + JWT (jjwt 0.12.6)
DatabasePostgreSQL
ORMSpring Data JPA + Hibernate
MigrationsFlyway
MappingMapStruct 1.6.3
ValidationJakarta Bean Validation
EmailSpring Mail (JavaMailSender)
API DocsSpringDoc OpenAPI (Swagger UI)
UtilitiesLombok

Prerequisites

  • Java 22+
  • Maven 3.9+
  • PostgreSQL 12+

Getting Started

1. Clone and create the database

git clone https://github.com/orestengabo0/JavaT.git
cd JavaT
CREATEDATABASEjavat;

2. Configure credentials

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 committedspring.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:8080

Then activate the local profile when running:

mvn spring-boot:run -Dspring-boot.run.profiles=local

Or set the environment variable in your IDE run configuration:

SPRING_PROFILES_ACTIVE=local

Generating a JWT secret:

openssl rand -base64 32

3. Run

mvn spring-boot:run

Flyway runs automatically on startup and creates all tables. A default admin account is seeded.

4. Open Swagger UI

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


Default Admin Account

Seeded by Flyway migration V2. Use this to test admin endpoints immediately.

FieldValue
NameEmmanuel Uwimana
Emailadmin@ubs.rw
PasswordAdmin@1234
RoleADMIN

Change this password before deploying anywhere.


Project Structure

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

API Reference

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" }
]
}

Authentication — /api/v1/auth (public)

MethodPathDescription
POST/registerRegister a new account. Sends a verification email. Returns tokens.
POST/loginLogin 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-verificationResend the verification email.
POST/forgot-passwordRequest a password reset email. Always returns 200.
POST/reset-passwordComplete 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"
}

User Management — /api/v1/users (requires JWT)

MethodPathRoleDescription
GET/meAnyGet own profile
PATCH/meAnyUpdate own name / username
PATCH/me/passwordAnyChange own password
POST/ADMINCreate a user (ACTIVE immediately)
GET/ADMINRead — list users (paginated + filtered)
GET/{id}ADMINRead — get user by ID
PATCH/{id}ADMINUpdate profile (name, username)
PATCH/{id}/roleADMINUpdate role
DELETE/{id}ADMINDelete user (soft-delete)
PATCH/{id}/deactivateADMINSoft-deactivate (same as delete)
PATCH/{id}/activateADMINRestore 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.


Features In Detail

API Response Wrapper

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:

returnResponseBuilder.ok(dto, "User retrieved", request);
returnResponseBuilder.created(dto, "Account created", request);
returnResponseBuilder.noContent();

JWT Authentication

  • Tokens are signed with HMAC-SHA256 using a Base64-encoded secret.
  • Access token default: 24 hours. Refresh token default: 7 days.
  • The sub claim is the user's email (not username).
  • A custom role claim is embedded so role checks don't require a DB lookup.
  • The JwtAuthenticationFilter runs before every request, extracts the token, validates it, and populates SecurityContextHolder.

Email Verification Flow

  1. User registers → status set to PENDING → verification email sent async.
  2. User clicks link → GET /api/v1/auth/verify-email?token=... → status set to ACTIVE.
  3. Login with PENDING status → 403 with code EMAIL_NOT_VERIFIED.
  4. Token expires after 24 hours → user can request a new one via /resend-verification.

Password Reset Flow

  1. POST /forgot-password → token created (15 min expiry) → email sent async.
  2. POST /reset-password with token + new password → password updated, token marked used.
  3. Always returns 200 on /forgot-password regardless of whether the email exists (prevents enumeration).

Base Entity

Every entity that extends BaseEntity automatically gets:

ColumnTypeDescription
idBIGINTAuto-generated primary key
created_atTIMESTAMPTZSet on INSERT, never updated
updated_atTIMESTAMPTZUpdated on every UPDATE
created_byVARCHAR(100)Principal who created the record
updated_byVARCHAR(100)Principal who last modified it
deletedBOOLEANSoft-delete flag
deleted_atTIMESTAMPTZWhen it was soft-deleted
deleted_byVARCHAR(100)Who soft-deleted it
statusVARCHAR(20)ACTIVE, INACTIVE, SUSPENDED, PENDING

Soft-delete a record:

entity.softDelete(currentUserEmail);
repository.save(entity);

Restore it:

entity.restore();
repository.save(entity);

Validation Layer

Custom annotations in common/validation/:

AnnotationWhat it validates
@ValidPassword8–72 chars, upper + lower + digit + special char
@NoWhitespaceNo leading or trailing spaces
@ValidEnumString 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) @RequestBodyRegisterRequestbody// Controller — only OnPatch constraints fire@Validated(ValidationGroups.OnPatch.class) @RequestBodyUpdateProfileRequestbody

Pagination & Filtering

Paginated response shape:

{
"content": [...],
"meta": {
"page": 0,
"size": 10,
"totalElements": 47,
"totalPages": 5,
"first": true,
"last": false,
"empty": false
}
}

In a service:

Specification<User> spec = newBaseSpecification<>(criteria);
Page<User> page = userRepository.findAll(spec, pageable);
returnPageResponse.of(page, userMapper::toDto);

In a controller:

Pageablepageable = 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:

  1. Make the repository extend JpaSpecificationExecutor<YourEntity>.
  2. Build a List<SearchCriteria> from request params.
  3. Pass to new BaseSpecification<>(criteria).

Email Service

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:

  1. Create templates/email/your-template.html with {{placeholder}} variables.
  2. Add a method to EmailService annotated with @Async("emailTaskExecutor").
  3. Call loadTemplate("your-template.html", Map.of(...)) and pass to send().

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=false

Open http://localhost:8025 to see all sent emails.

Global Exception Handling

GlobalExceptionHandler catches everything and returns a consistent ApiResponse. You never need to write try/catch in controllers or services for these cases:

ExceptionHTTP
MethodArgumentNotValidException400 — validation errors with field details
ConstraintViolationException400 — path/query param violations
ResourceNotFoundException404
DuplicateResourceException409
UnauthorizedException401
ForbiddenException403
DataIntegrityViolationException409 — DB unique constraint (parses field name from PostgreSQL/MySQL error)
TransactionSystemException400 — entity-level validation at commit time
AuthenticationException401
AccessDeniedException403
HttpMessageNotReadableException400 — malformed JSON
NoResourceFoundException404 — unknown URL
Exception500 — catch-all, real cause logged server-side only

Throwing domain errors in services:

thrownewResourceNotFoundException("User", "id", id);
thrownewDuplicateResourceException("User", "email", email);
thrownewForbiddenException("You can only modify your own resources");
thrownewBusinessException("Account is suspended", HttpStatus.FORBIDDEN);

Flyway Migrations

Migrations live in src/main/resources/db/migration/ and follow the naming convention V{version}__{description}.sql.

FileWhat it does
V1Creates the users table
V2Seeds the default admin user
V3Creates password_reset_tokens table
V4Creates email_verification_tokens table
V5Updates admin password hash

Adding a new migration:

  1. Create V6__your_description.sql in db/migration/.
  2. Write your SQL.
  3. 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.

Role-Based Access Control

Three roles: USER, MODERATOR, ADMIN.

Coarse-grained — on controller methods:

@PreAuthorize("hasRole('ADMIN')")
@GetMappingpublicResponseEntity<?> listAll(...) { ... }

Fine-grained — inside service methods:

if (!currentUser.getId().equals(resourceOwnerId)) {
thrownewForbiddenException("You can only modify your own resources");
}

@EnableMethodSecurity is active in SecurityConfig, so @PreAuthorize works on any Spring-managed bean.


Configuration Reference

All custom properties are prefixed with app.*:

# JWTapp.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# Mailapp.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 poolapp.async.core-pool-size=2
app.async.max-pool-size=5
app.async.queue-capacity=100
app.async.thread-name-prefix=async-email-
# Token expiryapp.auth.password-reset-token-expiry-minutes=15
app.auth.verification-token-expiry-hours=24

Using This Template for a New Project

  1. Clone the repository.
  2. Rename the package from com.spring.JavaT to com.yourcompany.yourapp (IDE refactor → rename package).
  3. Update spring.application.name in application.properties.
  4. Update pom.xml<groupId>, <artifactId>, and <name>.
  5. Create your database and update the datasource credentials.
  6. Generate a new JWT secret: openssl rand -base64 32.
  7. Configure your SMTP credentials.
  8. Run mvn spring-boot:run — Flyway creates the schema automatically.
  9. Start building your domain features on top.

Extending the Template

Adding a new entity

@Entity@Table(name = "products")
publicclassProductextendsBaseEntity {
// Your fields — id, timestamps, audit, soft-delete, status come from BaseEntityprivateStringname;
privateBigDecimalprice;
}

Adding a new paginated + filtered endpoint

// RepositorypublicinterfaceProductRepositoryextendsJpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> { }
// ServicepublicPageResponse<ProductDto> findAll(List<SearchCriteria> criteria, Pageablepageable) {
Specification<Product> spec = newBaseSpecification<>(criteria);
returnPageResponse.of(productRepository.findAll(spec, pageable), productMapper::toDto);
}
// Controller@GetMappingpublicResponseEntity<ApiResponse<PageResponse<ProductDto>>> list(
@RequestParam(required = false) Integerpage,
@RequestParam(required = false) Integersize,
@RequestParam(required = false) StringsortBy,
@RequestParam(required = false) StringsortDir,
@RequestParam(required = false) Stringname,
HttpServletRequestrequest) {
Pageablepageable = PaginationUtil.toPageable(page, size, sortBy, sortDir,
Set.of("id", "name", "price", "createdAt"));
List<SearchCriteria> criteria = newArrayList<>();
if (name != null) criteria.add(newSearchCriteria("name", SearchCriteria.Op.LIKE, name));
returnResponseBuilder.ok(
productService.findAll(criteria, pageable),
"Products retrieved successfully",
request);
}

Adding a new email type

// 1. Create templates/email/welcome.html with {{firstName}}, {{appName}} placeholders// 2. Add to EmailService@Async("emailTaskExecutor")
publicvoidsendWelcomeEmail(StringtoEmail, StringfirstName) {
Stringbody = 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());
}

Automated testing

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.ps1

Admin credentials: admin@ubs.rw / Admin@1234


License

MIT — use freely for personal and commercial projects.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages