Balaka Developer: Extending Balaka

Architecture

Technology Stack

LayerTechnologyVersion
LanguageJava25 (virtual threads)
FrameworkSpring Boot4.0
ORMSpring Data JPA / Hibernate
DatabasePostgreSQL18
MigrationsFlyway
TemplatesThymeleaf
Frontend interactivityHTMX + Alpine.js (CSP build)
CSSBootstrap + Tailwind CSS
TestingJUnit 5, Testcontainers, Playwright
Security analysisSpotBugs, OWASP ZAP
API docsspringdoc-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:

  1. Web controllers (controller/) -- render Thymeleaf templates, handle form submissions, return HTML fragments for HTMX. Use @Controller annotation and @PreAuthorize for method-level security.

  2. API controllers (controller/api/) -- return JSON, use @RestController. Secured via Bearer token scopes (@PreAuthorize("hasAuthority('SCOPE_...')")). All documented with @Tag, @Operation, @ApiResponse from springdoc.

Service Layer

Contains all business logic. Key patterns:

  • @Transactional on 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).
  • rowVersion for optimistic locking (@Version).
  • Audit fields: createdAt, updatedAt, createdBy, updatedBy.
  • Soft delete: deletedAt field, 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

FileContent
V001__security.sqlUsers, roles, permissions, device codes/tokens
V002__core_schema.sqlCOA, templates, transactions, journal entries
V003__feature_schema.sqlPayroll, tax, assets, bank reconciliation, alerts, bills, inventory
V004__seed_data.sqlDefault 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.LAZY for all @ManyToOne relationships.
  • Use @NotNull, @NotBlank, @Size for 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:

  • @Transactional on write methods, @Transactional(readOnly = true) on read methods.
  • Throw exceptions for invalid state instead of using fallback values.
  • Use softDelete() instead of delete() for entities extending BaseEntity.

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 @RestController and @RequestMapping("/api/...").
  • Annotate with @Tag for Swagger grouping.
  • Class-level @PreAuthorize for 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.sql for core accounting tables.
  • V003__feature_schema.sql for feature-specific tables.
  • V004__seed_data.sql for 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.java and 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

PackIndustryPKP StatusKey Features
it-serviceIT ServicesPKP75 COA accounts, PPN templates, PPh 23 withholding
online-sellerE-commerceNon-PKPMarketplace templates, inventory accounts
coffee-shopFood & BeverageNon-PKPProduction/BOM templates, inventory
campusEducationNon-PKPTuition 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_id pointing 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 just amount.
  • 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 when id_account is null. The API caller provides the actual account via accountSlots keyed by this hint (e.g., "BANK").
  • formula -- SpEL expression. Common: amount, amount * 0.11, grossSalary.
  • line_type -- DEBIT or CREDIT.
  • 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., industry parsed as established_date).
  • Transaction cascade: TRUNCATE transactions CASCADE cascades to production_orders (FK id_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

LevelToolData SourceLocationCount
UnitJUnit 5 + MockitoMockssrc/test/java/.../unit/Fast
IntegrationTestcontainers (PostgreSQL)V800-V912 migrationssrc/test/java/.../integration/~30s startup
Functional (E2E)PlaywrightIndustry seed initializerssrc/test/java/.../functional/~60-90 min total
Security (SAST)SpotBugsSource codeN/A~1 min
Security (DAST)OWASP ZAPRunning appsrc/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/:

MigrationContent
V800__base_test_data.sqlBase test data (users, roles)
V900-V912Domain-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 tests
  • functional/manufacturing/ -- coffee shop tests
  • functional/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 use assertThat(option).isVisible(). Use evaluate("el => el.value") to get the selected value.

  • text=X is a substring match. Use .font-medium:text-is('Exact Text') or getByText(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 LAZY will throw LazyInitializationException outside a transaction. Use JOIN FETCH in repository queries or @Transactional on the test class.

  • Locator specificity: a[href*='/report'] can match sidebar links. Use more specific selectors like a[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:

  1. Add an exclusion to spotbugs-exclude.xml.
  2. Include a detailed comment explaining why it is a false positive.
  3. 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.

FilePurpose
V001__security.sqlUsers, roles, permissions tables
V002__core_schema.sqlCore accounting tables
V003__feature_schema.sqlFeature-specific tables
V004__seed_data.sqlDefault seed data (roles, permissions, system templates)

If a migration has already been applied to production, you must:

  1. Create a new migration file (V005+).
  2. 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:check before committing.

PR Guidelines

  • One feature or fix per PR.
  • Include the Playwright test that verifies the feature.
  • Ensure SpotBugs passes.
  • Update CLAUDE.md status section if completing a milestone.

Previous: Testing