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)

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). Only industry and 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_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

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

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