Architecture
Technology Stack
| Layer | Technology | Version |
|---|---|---|
| Language | Java | 25 (virtual threads) |
| Framework | Spring Boot | 4.0 |
| ORM | Spring Data JPA / Hibernate | |
| Database | PostgreSQL | 18 |
| Migrations | Flyway | |
| Templates | Thymeleaf | |
| Frontend interactivity | HTMX + Alpine.js (CSP build) | |
| CSS | Bootstrap + Tailwind CSS | |
| Testing | JUnit 5, Testcontainers, Playwright | |
| Security analysis | SpotBugs, OWASP ZAP | |
| API docs | springdoc-openapi (Swagger UI) | 3.0.1 |
Application Layers
HTTP Request
│
├── Controller (MVC) src/.../controller/
│ ├── Web controllers Thymeleaf views, HTMX partials
│ └── API controllers src/.../controller/api/ REST JSON
│
├── Service src/.../service/
│ └── Business logic, transaction management, calculations
│
├── Repository src/.../repository/
│ └── Spring Data JPA interfaces, custom queries
│
└── Entity src/.../entity/
└── JPA entities, 85+ tables, UUID primary keys
Controller Layer
Two types of controllers:
-
Web controllers (
controller/) -- render Thymeleaf templates, handle form submissions, return HTML fragments for HTMX. Use@Controllerannotation and@PreAuthorizefor method-level security. -
API controllers (
controller/api/) -- return JSON, use@RestController. Secured via Bearer token scopes (@PreAuthorize("hasAuthority('SCOPE_...')")). All documented with@Tag,@Operation,@ApiResponsefrom springdoc.
Service Layer
Contains all business logic. Key patterns:
@Transactionalon write methods.- Services call repositories, never other controllers.
- Formula evaluation uses SpEL with
SimpleEvaluationContext.forReadOnlyDataBinding()(sandboxed). - Tax calculations (PPh 21, PPN, PPh 23) are in dedicated services.
Repository Layer
Spring Data JPA interfaces extending JpaRepository<Entity, UUID>. Custom queries use @Query with JPQL. Soft-deleted records are filtered via @SQLRestriction("deleted_at IS NULL") on entities.
Entity Layer
All primary entities extend BaseEntity which provides:
- UUID primary key (auto-generated).
rowVersionfor optimistic locking (@Version).- Audit fields:
createdAt,updatedAt,createdBy,updatedBy. - Soft delete:
deletedAtfield,softDelete()/isDeleted()methods.
Child tables (e.g., BankStatementItem, ReconciliationItem) have their own UUID PK but do not extend BaseEntity -- no version/audit fields.
Deployment Model
Single-tenant: each company gets its own application instance and PostgreSQL database. No multi-tenancy logic in the code.
Frontend Architecture
Server-side rendering with progressive enhancement:
- Thymeleaf renders full pages and fragments.
- HTMX handles partial page updates (form submissions, dynamic lists) without full page reloads.
- Alpine.js (CSP build) provides client-side reactivity (form validation, conditional display, calculations).
Alpine.js CSP requirement: all Alpine components must be registered via Alpine.data() in alpine-components.js. No inline x-data expressions in templates -- the CSP policy blocks unsafe-eval.
Database
- PostgreSQL 18 with SSL connections (
sslmode=require). - Connection pooling via HikariCP (10 max connections, 2 minimum idle).
- Flyway manages schema migrations (V001--V004 consolidated).
ddl-auto=validate-- Hibernate validates schema against entities but never modifies it.
Testing Infrastructure
- Testcontainers spins up PostgreSQL for integration and functional tests.
- Playwright drives browser-based functional tests against the running application.
- SpotBugs performs static security analysis.
- OWASP ZAP runs DAST (Dynamic Application Security Testing) against the running app.
Next: Project Structure
Project Structure
Source Layout
src/main/java/com/artivisi/accountingfinance/
├── AccountingFinanceApplication.java Main class
├── config/ Spring configuration
│ ├── SecurityConfig.java Spring Security setup
│ ├── OpenApiConfig.java Swagger/OpenAPI config
│ ├── ThemeConfig.java Per-client theming
│ ├── WebMvcConfig.java MVC interceptors
│ └── ...
├── controller/ Web controllers (Thymeleaf)
│ ├── DashboardController.java
│ ├── TransactionController.java
│ ├── DeviceAuthorizationController.java
│ └── api/ REST API controllers
│ ├── TransactionApiController.java
│ ├── DeviceAuthApiController.java
│ ├── FinancialAnalysisApiController.java
│ ├── PayrollApiController.java
│ ├── TaxExportApiController.java
│ └── ... (20+ API controllers)
├── dto/ Request/response records
│ ├── CreateTransactionRequest.java
│ ├── TransactionResponse.java
│ └── ...
├── entity/ JPA entities (85+)
│ ├── BaseEntity.java UUID PK, audit, soft delete
│ ├── Transaction.java
│ ├── JournalEntry.java
│ ├── ChartOfAccount.java
│ └── ...
├── enums/ Enum types
│ ├── AccountType.java
│ ├── VoidReason.java
│ └── ...
├── exception/ Custom exceptions
├── repository/ Spring Data JPA repositories
├── scheduler/ Scheduled tasks (payroll, alerts)
├── security/ Security components
│ ├── Permission.java Authority string constants
│ ├── BearerTokenAuthenticationFilter.java
│ └── ...
├── service/ Business logic
│ ├── TransactionService.java
│ ├── JournalService.java
│ ├── PayrollService.java
│ ├── DeviceAuthService.java
│ └── ...
└── util/ Utility classes
Resources
src/main/resources/
├── application.properties Main config
├── db/migration/ Flyway production migrations
│ ├── V001__security.sql Users, roles, permissions
│ ├── V002__core_schema.sql COA, templates, transactions, journals
│ ├── V003__feature_schema.sql Payroll, tax, assets, alerts, etc.
│ └── V004__seed_data.sql Default seed data
├── static/ CSS, JS, images
│ └── js/alpine-components.js All Alpine.js components (CSP)
└── templates/ Thymeleaf templates
├── fragments/ Shared fragments (main.html layout)
├── dashboard.html
├── accounts/
├── transactions/
└── ...
Test Layout
src/test/
├── java/.../
│ ├── functional/ Playwright browser tests
│ │ ├── ChartOfAccountsTest.java
│ │ ├── TransactionTest.java
│ │ ├── seller/ Online seller industry tests
│ │ ├── manufacturing/ Coffee shop industry tests
│ │ └── campus/ Campus industry tests
│ ├── integration/ Testcontainers tests
│ ├── unit/ Unit tests
│ └── controller/ Controller unit tests
└── resources/
└── db/test/integration/ Test-only migrations
├── V800__base_test_data.sql
├── V900-V912 Domain-specific test data
└── cleanup-for-clear-test.sql
Industry Seed Packs
industry-seed/
├── it-service/seed-data/ IT services company (PKP)
│ ├── 01_company_config.csv
│ ├── 02_chart_of_accounts.csv
│ ├── 03_salary_components.csv
│ ├── 04_journal_templates.csv
│ ├── 05_journal_template_lines.csv
│ ├── ...
│ └── MANIFEST.md
├── online-seller/seed-data/ E-commerce seller
├── coffee-shop/seed-data/ Coffee shop / F&B
└── campus/seed-data/ Campus / educational institution
Seed packs are loaded via DataImportService and contain numbered CSV files for deterministic import order.
Key Files
BaseEntity
src/.../entity/BaseEntity.java -- all primary entities extend this.
@MappedSuperclass
public abstract class BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Version
private Long rowVersion; // Optimistic locking
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
private String updatedBy;
private LocalDateTime deletedAt; // Soft delete
public void softDelete() { ... }
public boolean isDeleted() { ... }
}
Permission.java
src/.../security/Permission.java -- utility class with public static final String constants for all authorities. Not an enum.
public final class Permission {
public static final String TRANSACTION_VIEW = "TRANSACTION_VIEW";
public static final String TRANSACTION_CREATE = "TRANSACTION_CREATE";
public static final String TRANSACTION_POST = "TRANSACTION_POST";
// ... 60+ permission constants
// Role sets
public static final Set<String> ADMIN_PERMISSIONS = Set.of(...);
public static final Set<String> OWNER_PERMISSIONS = Set.of(...);
}
Used in @PreAuthorize("hasAuthority('TRANSACTION_CREATE')") annotations.
ViewConstants.java
Contains page name constants and redirect prefix strings used by web controllers to avoid string duplication.
Migration Files
| File | Content |
|---|---|
V001__security.sql | Users, roles, permissions, device codes/tokens |
V002__core_schema.sql | COA, templates, transactions, journal entries |
V003__feature_schema.sql | Payroll, tax, assets, bank reconciliation, alerts, bills, inventory |
V004__seed_data.sql | Default roles, permissions, system templates |
Pre-production strategy: modify existing V001--V004 files instead of creating new migration files. See Contributing for details.
Next: Adding Features
Adding Features
Step-by-step guide for adding a new feature to Balaka. Follow existing patterns in the codebase.
1. Define the Entity
Create a new entity in src/.../entity/. Extend BaseEntity for primary entities.
@Entity
@Table(name = "widgets")
@Getter @Setter
@SQLRestriction("deleted_at IS NULL") // Soft delete filter
public class Widget extends BaseEntity {
@NotBlank
@Column(name = "name", nullable = false, length = 255)
private String name;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private WidgetStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_category")
private WidgetCategory category;
}
Key conventions:
- Table and column names use
snake_case. - UUID primary key via
BaseEntity. @SQLRestriction("deleted_at IS NULL")for soft delete filtering.FetchType.LAZYfor all@ManyToOnerelationships.- Use
@NotNull,@NotBlank,@Sizefor validation.
For child/detail tables that do not need audit or soft delete, use a standalone UUID PK without extending BaseEntity:
@Entity
@Table(name = "widget_items")
@Getter @Setter
public class WidgetItem {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_widget", nullable = false)
private Widget widget;
@Column(name = "quantity")
private Integer quantity;
}
2. Add the Enum (if needed)
Create in src/.../enums/:
public enum WidgetStatus {
ACTIVE,
INACTIVE,
ARCHIVED
}
3. Create the Repository
public interface WidgetRepository extends JpaRepository<Widget, UUID> {
List<Widget> findByStatus(WidgetStatus status);
@Query("SELECT w FROM Widget w JOIN FETCH w.category WHERE w.id = :id")
Optional<Widget> findByIdWithCategory(@Param("id") UUID id);
}
Use JOIN FETCH in custom queries to avoid N+1 problems with lazy-loaded relationships.
4. Implement the Service
@Service
@RequiredArgsConstructor
@Slf4j
public class WidgetService {
private final WidgetRepository widgetRepository;
@Transactional
public Widget create(Widget widget) {
log.info("Creating widget: {}", widget.getName());
return widgetRepository.save(widget);
}
@Transactional(readOnly = true)
public Page<Widget> findAll(Pageable pageable) {
return widgetRepository.findAll(pageable);
}
@Transactional
public void delete(UUID id) {
Widget widget = widgetRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(
"Widget not found: " + id));
widget.softDelete();
widgetRepository.save(widget);
}
}
Conventions:
@Transactionalon write methods,@Transactional(readOnly = true)on read methods.- Throw exceptions for invalid state instead of using fallback values.
- Use
softDelete()instead ofdelete()for entities extendingBaseEntity.
5. Add Web Controller
@Controller
@RequestMapping("/widgets")
@RequiredArgsConstructor
public class WidgetController {
private final WidgetService widgetService;
@GetMapping
@PreAuthorize("hasAuthority('WIDGET_VIEW')")
public String list(Model model, Pageable pageable) {
model.addAttribute("widgets", widgetService.findAll(pageable));
return "widgets/list";
}
@PostMapping
@PreAuthorize("hasAuthority('WIDGET_CREATE')")
public String create(@Valid Widget widget, BindingResult result) {
if (result.hasErrors()) {
return "widgets/form";
}
widgetService.create(widget);
return "redirect:/widgets";
}
}
6. Add API Controller (if needed)
@RestController
@RequestMapping("/api/widgets")
@Tag(name = "Widgets", description = "Widget management API")
@PreAuthorize("hasAuthority('SCOPE_widgets:read')")
@RequiredArgsConstructor
@Slf4j
public class WidgetApiController {
private final WidgetService widgetService;
@GetMapping
@Operation(summary = "List widgets")
public ResponseEntity<Page<WidgetResponse>> list(Pageable pageable) {
return ResponseEntity.ok(
widgetService.findAll(pageable).map(WidgetResponse::from));
}
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_widgets:write')")
@Operation(summary = "Create a widget")
public ResponseEntity<WidgetResponse> create(
@Valid @RequestBody CreateWidgetRequest request) {
Widget widget = widgetService.create(toEntity(request));
return ResponseEntity.status(HttpStatus.CREATED)
.body(WidgetResponse.from(widget));
}
}
API controller conventions:
- Place in
controller/api/package. - Use
@RestControllerand@RequestMapping("/api/..."). - Annotate with
@Tagfor Swagger grouping. - Class-level
@PreAuthorizefor the read scope, method-level for write. - Use records for request/response DTOs.
7. Add Thymeleaf Template
Create templates in src/main/resources/templates/widgets/. Use the layout from fragments/main.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{fragments/main :: layout(~{::title}, ~{::content})}">
<head><title>Widgets</title></head>
<body>
<div th:fragment="content">
<h1 class="text-2xl font-bold mb-4">Widgets</h1>
<!-- content here -->
</div>
</body>
</html>
For HTMX interactions, return HTML fragments from the controller.
8. Add Database Schema
Modify the appropriate migration file:
V002__core_schema.sqlfor core accounting tables.V003__feature_schema.sqlfor feature-specific tables.V004__seed_data.sqlfor seed data.
-- In V003__feature_schema.sql
CREATE TABLE widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
id_category UUID REFERENCES widget_categories(id),
row_version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by VARCHAR(100),
updated_by VARCHAR(100),
deleted_at TIMESTAMP
);
9. Add Permissions
Add permission constants in Permission.java:
public static final String WIDGET_VIEW = "WIDGET_VIEW";
public static final String WIDGET_CREATE = "WIDGET_CREATE";
public static final String WIDGET_EDIT = "WIDGET_EDIT";
public static final String WIDGET_DELETE = "WIDGET_DELETE";
Add to the appropriate role sets (ADMIN_PERMISSIONS, OWNER_PERMISSIONS, etc.).
Add to V004__seed_data.sql:
INSERT INTO permissions (id, name) VALUES
(gen_random_uuid(), 'WIDGET_VIEW'),
(gen_random_uuid(), 'WIDGET_CREATE'),
(gen_random_uuid(), 'WIDGET_EDIT'),
(gen_random_uuid(), 'WIDGET_DELETE');
If the feature needs API access, add the scope to DeviceAuthService:
deviceToken.setScopes("...,widgets:read,widgets:write");
10. Add Sidebar Entry
Add navigation in fragments/main.html (both desktop and mobile sidebars). The sidebar uses <details> elements for collapsible groups.
11. Write Tests
See Testing for the test pyramid and patterns.
Checklist
- [ ] Entity with
BaseEntity(or standalone UUID PK for child tables) - [ ] Repository with custom queries using
JOIN FETCH - [ ] Service with
@Transactional - [ ] Web controller with
@PreAuthorize - [ ] API controller with
@Tag,@Operation, scope-based auth - [ ] Thymeleaf template using layout fragment
- [ ] Migration in V002/V003
- [ ] Permissions in
Permission.javaand V004 - [ ] Sidebar entry in
main.html - [ ] Functional test (Playwright)
- [ ] SpotBugs passes (
./mvnw spotbugs:check)
Next: Seed Packs
Seed Packs
Seed packs provide industry-specific starter data: chart of accounts, journal templates, salary components, tax deadlines, and more. They are loaded via DataImportService during initial setup or via the data import API.
Directory Structure
industry-seed/<industry-name>/seed-data/
├── 01_company_config.csv
├── 02_chart_of_accounts.csv
├── 03_salary_components.csv
├── 04_journal_templates.csv
├── 05_journal_template_lines.csv
├── 06_journal_template_tags.csv
├── 07_clients.csv
├── 08_projects.csv
├── 09_project_milestones.csv
├── 10_project_payment_terms.csv
├── 11_fiscal_periods.csv
├── 12_tax_deadlines.csv
├── 13_company_bank_accounts.csv
├── 14_merchant_mappings.csv
├── 15_employees.csv
├── 16_employee_salary_components.csv
├── 17_invoices.csv
├── 18_transactions.csv
├── 19_transaction_account_mappings.csv
├── 20_journal_entries.csv
├── 21_payroll_runs.csv
├── 22_payroll_details.csv
├── 23_amortization_schedules.csv
├── 24_amortization_entries.csv
├── 25_tax_transaction_details.csv
├── 26_tax_deadline_completions.csv
├── 27_draft_transactions.csv
├── 30_user_template_preferences.csv
├── 31_telegram_user_links.csv
├── 32_audit_logs.csv
├── 33_transaction_sequences.csv
├── 34_asset_categories.csv
├── MANIFEST.md
└── documents/
Files are numbered to enforce import order (foreign key dependencies). The MANIFEST.md documents the pack contents and version.
Existing Packs
| Pack | Industry | PKP Status | Key Features |
|---|---|---|---|
it-service | IT Services | PKP | 75 COA accounts, PPN templates, PPh 23 withholding |
online-seller | E-commerce | Non-PKP | Marketplace templates, inventory accounts |
coffee-shop | Food & Beverage | Non-PKP | Production/BOM templates, inventory |
campus | Education | Non-PKP | Tuition revenue, multi-program COA |
Creating a New Seed Pack
1. Create Directory
mkdir -p industry-seed/<name>/seed-data/
2. Company Config (01_company_config.csv)
id,company_name,npwp,address,city,province,phone,email,currency_code,fiscal_year_start_month,is_pkp,industry,established_date
e0000000-0000-0000-0000-000000000099,My Company,12.345.678.9-012.000,Jl. Example 1,Jakarta,DKI Jakarta,021-1234567,info@example.com,IDR,1,false,retail,2024-01-01
The industry field is used by AI analysis to determine relevant KPIs and analysis types.
3. Chart of Accounts (02_chart_of_accounts.csv)
Design the COA following SAK EMKM structure:
id,code,name,account_type,parent_id,is_header,is_active,normal_balance,description
uuid-1,1-0000,ASET,ASSET,,true,true,DEBIT,Header: Assets
uuid-2,1-1000,Aset Lancar,ASSET,uuid-1,true,true,DEBIT,Header: Current Assets
uuid-3,1-1100,Kas,ASSET,uuid-2,false,true,DEBIT,Cash on hand
Account type values: ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE.
Conventions:
- Use 5-digit codes:
X-XXXX(type digit, dash, 4-digit sequence). - Header accounts (
is_header=true) cannot be used in transactions. - Every leaf account must have a
parent_idpointing to a header.
4. Journal Templates (04_journal_templates.csv + 05_journal_template_lines.csv)
Templates define how business events map to journal entries.
# 04_journal_templates.csv
id,name,transaction_type,category,cash_flow_category,is_system,template_type,description
uuid-t1,Penjualan Tunai,PJ,REVENUE,OPERATING,false,SIMPLE,Cash sales
Template types:
SIMPLE-- single amount field, formula is justamount.DETAILED-- multiple formula variables (e.g.,assetCost,ppn,grossSalary).
# 05_journal_template_lines.csv
id,id_template,line_order,line_type,id_account,account_hint,mapping_key,formula,is_required,description
uuid-l1,uuid-t1,1,DEBIT,uuid-kas,,amount,amount,true,Cash received
uuid-l2,uuid-t1,2,CREDIT,uuid-revenue,,amount,amount,true,Sales revenue
Line fields:
id_account-- fixed account UUID. Leave null if user-selectable.account_hint-- used whenid_accountis null. The API caller provides the actual account viaaccountSlotskeyed by this hint (e.g.,"BANK").formula-- SpEL expression. Common:amount,amount * 0.11,grossSalary.line_type--DEBITorCREDIT.line_order-- determines display order.
5. Salary Components (03_salary_components.csv)
Indonesian payroll components:
id,code,name,component_type,is_taxable,formula,is_active,is_system,line_order
uuid-sc1,GAJI_POKOK,Gaji Pokok,EARNING,true,baseSalary,true,true,1
uuid-sc2,JKK,JKK (0.24%),COMPANY_CONTRIBUTION,false,baseSalary * 0.0024,true,true,2
Component types: EARNING, DEDUCTION, COMPANY_CONTRIBUTION.
6. Tax Deadlines (12_tax_deadlines.csv)
id,id_fiscal_period,tax_type,reporting_deadline,payment_deadline,description
uuid-td1,uuid-fp1,PPH_21,2025-02-20,2025-02-10,PPh 21 January 2025
7. Asset Categories (34_asset_categories.csv)
id,name,useful_life_years,depreciation_method,fiscal_asset_group
uuid-ac1,Computer Equipment,4,STRAIGHT_LINE,KELOMPOK_1
Testing Seed Packs
Functional tests use @TestConfiguration with @PostConstruct initializers to load seed data. Each industry has its own test initializer:
@TestConfiguration
@Profile("functional")
public class MyIndustryTestDataInitializer {
@Autowired
private DataImportService dataImportService;
@PostConstruct
public void init() {
dataImportService.importFromSeedPack("my-industry");
}
}
Tests reference this initializer via @Import(MyIndustryTestDataInitializer.class).
Gotchas
- CSV column count: when adding columns to a CSV, count commas carefully. Off-by-one errors cause values to land in wrong columns (e.g.,
industryparsed asestablished_date). - Transaction cascade:
TRUNCATE transactions CASCADEcascades toproduction_orders(FKid_transaction). If your test data includes production orders, do not truncate transactions. - UUID consistency: use deterministic UUIDs in seed data (e.g.,
e0000000-0000-0000-0000-000000000001) so templates can reference accounts and other entities by known IDs.
Next: Testing
Testing
Test Pyramid
| Level | Tool | Data Source | Location | Count |
|---|---|---|---|---|
| Unit | JUnit 5 + Mockito | Mocks | src/test/java/.../unit/ | Fast |
| Integration | Testcontainers (PostgreSQL) | V800-V912 migrations | src/test/java/.../integration/ | ~30s startup |
| Functional (E2E) | Playwright | Industry seed initializers | src/test/java/.../functional/ | ~60-90 min total |
| Security (SAST) | SpotBugs | Source code | N/A | ~1 min |
| Security (DAST) | OWASP ZAP | Running app | src/test/java/.../functional/ZapDastTest.java | ~5-15 min |
Running Tests
Full Test Suite
The full test suite takes 60-90 minutes. Always run in background with log capture. Never run multiple instances simultaneously.
# Background execution with log capture
nohup ./mvnw test > target/test-output.log 2>&1 &
# Or foreground with tee
./mvnw test 2>&1 | tee target/test-output.log
Specific Test
./mvnw test -Dtest=TransactionTest
Visible Browser (Debugging)
./mvnw test -Dtest=TransactionTest -Dplaywright.headless=false -Dplaywright.slowmo=100
SpotBugs Only
./mvnw spotbugs:check
# Results: target/spotbugsXml.xml
DAST Only
# Full scan (active + passive, ~15 min)
./mvnw test -Dtest=ZapDastTest
# Quick scan (passive only, ~1 min)
./mvnw test -Dtest=ZapDastTest -Ddast.quick=true
# Results: target/security-reports/zap-*.html
Test Data Strategy
Integration Tests
Use Flyway test migrations in src/test/resources/db/test/integration/:
| Migration | Content |
|---|---|
V800__base_test_data.sql | Base test data (users, roles) |
V900-V912 | Domain-specific data (templates, transactions, payroll, drafts, etc.) |
These migrations run automatically when Testcontainers spins up the PostgreSQL instance.
Functional Tests
Functional tests do NOT use migration-based test data. Instead, they use @TestConfiguration initializers that load industry seed packs programmatically:
@TestConfiguration
@Profile("functional")
public class ItServiceTestDataInitializer {
@Autowired
private DataImportService dataImportService;
@PostConstruct
public void init() {
dataImportService.importFromSeedPack("it-service");
}
}
Test classes import the relevant initializer:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("functional")
@Import(ItServiceTestDataInitializer.class)
class TransactionTest {
// ...
}
Industry-specific tests have their own subdirectories:
functional/seller/-- online seller testsfunctional/manufacturing/-- coffee shop testsfunctional/campus/-- campus tests
Writing Functional Tests (Playwright)
Basic Structure
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("functional")
class WidgetTest {
@LocalServerPort
private int port;
private Playwright playwright;
private Browser browser;
private Page page;
@BeforeEach
void setup() {
playwright = Playwright.create();
browser = playwright.chromium().launch(
new BrowserType.LaunchOptions().setHeadless(true));
page = browser.newPage();
}
@AfterEach
void teardown() {
browser.close();
playwright.close();
}
@Test
void shouldCreateWidget() {
// Login
page.navigate("http://localhost:" + port + "/login");
page.fill("input[name=username]", "admin");
page.fill("input[name=password]", "password");
page.click("button[type=submit]");
// Navigate and interact
page.navigate("http://localhost:" + port + "/widgets");
// ... assertions
}
}
Playwright Gotchas
-
<option>elements in<select>are NOT "visible" to Playwright. Do not useassertThat(option).isVisible(). Useevaluate("el => el.value")to get the selected value. -
text=Xis a substring match. Use.font-medium:text-is('Exact Text')orgetByText(text, new Page.GetByTextOptions().setExact(true))when precision is needed. -
<form>inside<tr>is invalid HTML. The browser strips it. Use card layout with forms instead of table rows wrapping forms. -
Lazy fetch in tests: entity relationships marked
LAZYwill throwLazyInitializationExceptionoutside a transaction. UseJOIN FETCHin repository queries or@Transactionalon the test class. -
Locator specificity:
a[href*='/report']can match sidebar links. Use more specific selectors likea[href*='reconciliations'][href*='report'].
Writing Integration Tests
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class WidgetRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:18");
@Autowired
private WidgetRepository widgetRepository;
@Test
void shouldFindByStatus() {
// Test repository methods against real PostgreSQL
}
}
SpotBugs
The project maintains a zero-issue SpotBugs policy. Any new exclusions in spotbugs-exclude.xml must include comprehensive justifications with mitigation details.
./mvnw spotbugs:check
If SpotBugs reports a false positive:
- Add an exclusion to
spotbugs-exclude.xml. - Include a detailed comment explaining why it is a false positive.
- Document what mitigation is in place.
Next: Contributing
Contributing
Code Style
- Java 25 features are used where appropriate (records, pattern matching, virtual threads).
- Use Lombok (
@Getter,@Setter,@RequiredArgsConstructor,@Slf4j) to reduce boilerplate. - Use Java records for DTOs (request/response objects).
- Entity field names use camelCase; database columns use snake_case.
- Package imports: no wildcards.
Error Handling
No fallback values. No silent defaults. Throw exceptions with descriptive messages:
// Correct
Widget widget = widgetRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Widget not found: " + id));
// Wrong: silent fallback
Widget widget = widgetRepository.findById(id).orElse(new Widget());
Log errors at appropriate levels and propagate them to the caller.
SpotBugs Zero-Issue Policy
All code must pass SpotBugs analysis with zero issues:
./mvnw spotbugs:check
If you encounter a false positive, add an exclusion to spotbugs-exclude.xml with a justification comment:
<!-- False positive: XSS prevention handled by Thymeleaf auto-escaping.
All user input rendered via th:text (escaped) not th:utext.
CSP headers prevent inline script execution. -->
<Match>
<Class name="com.artivisi.accountingfinance.controller.MyController"/>
<Bug pattern="XSS_SERVLET"/>
</Match>
Migration Strategy
Pre-production: modify existing migration files (V001-V004) instead of creating new ones. The migrations have not been applied to external databases that would break on checksum changes.
| File | Purpose |
|---|---|
V001__security.sql | Users, roles, permissions tables |
V002__core_schema.sql | Core accounting tables |
V003__feature_schema.sql | Feature-specific tables |
V004__seed_data.sql | Default seed data (roles, permissions, system templates) |
If a migration has already been applied to production, you must:
- Create a new migration file (V005+).
- Or manually fix the schema on production and update the checksum in
flyway_schema_history.
See docs/03-operations-guide.md for the production migration caveat.
Alpine.js CSP Requirement
All Alpine.js components must use Alpine.data() registration in src/main/resources/static/js/alpine-components.js. No inline expressions in Thymeleaf templates.
// alpine-components.js
Alpine.data('widgetForm', () => ({
amount: 0,
tax: 0,
calculateTax() {
this.tax = this.amount * 0.11;
}
}));
<!-- Template: reference by name only -->
<div x-data="widgetForm">
<input type="number" x-model="amount" @input="calculateTax()">
<span x-text="tax"></span>
</div>
Do not use inline x-data with object literals:
<!-- WRONG: blocked by CSP -->
<div x-data="{ amount: 0, tax: 0 }">
Feature Completion Criteria
A feature is only marked complete when it has a passing Playwright functional test. Unit and integration tests alone are not sufficient for feature sign-off.
Documentation
- Use English for developer documentation, code comments, and API descriptions.
- Use Indonesian for user-facing UI text (labels, messages, templates).
- No marketing language. Strictly technical.
- Do not create documentation files unless explicitly requested.
Commit Guidelines
- Concise commit messages focused on "why" not "what".
- Reference issue/bug numbers when applicable (e.g.,
BUG-014). - Run
./mvnw spotbugs:checkbefore committing.
PR Guidelines
- One feature or fix per PR.
- Include the Playwright test that verifies the feature.
- Ensure SpotBugs passes.
- Update
CLAUDE.mdstatus section if completing a milestone.
Previous: Testing