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)
A seed pack carries no company identity. Its single row leaves company_name and the other identity columns empty and sets only industry and the posting accounts, by COA code:
company_name,company_address,company_phone,company_email,tax_id,npwp,nitku,fiscal_year_start_month,currency_code,signing_officer_name,signing_officer_title,company_logo_path,established_date,is_pkp,pkp_since,industry,id_receivable_account,id_payable_account,id_output_tax_account,id_input_tax_account
,,,,,,,,,,,,,,,online-seller,1.1.10,2.1.02,2.1.03,1.1.25
Import rules for this file:
- Row with
company_name: replaces the company config; empty account columns clear that account. - Row without
company_name: updates the existing company config (created on first use when absent). Onlyindustryand the account columns that are filled are applied. Any other filled column rejects the import. - An account code that is not in the chart of accounts rejects the whole import.
The industry field is used by AI analysis to determine relevant KPIs and analysis types. The receivable/payable accounts drive the dashboard AR/AP KPIs and /api/analysis/{snapshot,receivables,payables}; invoices and bills also require the output/input tax accounts. Without them those screens show "Akun ... belum dikonfigurasi di Pengaturan Perusahaan".
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
V001-V004 have been applied to production databases, so editing them breaks Flyway checksum validation there and forces the schema to be created by hand. Add new schema as a new migration file (V005 onwards).
| 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