Commit 4d0dd9feca9f8999afe976cc0245277262a946a8

Authored by zichun
1 parent 34c8c92f

feat(finance): chart of accounts — Account entity, seeded GL, SPA page

First step of pbc-finance GL growth: the chart of accounts.

Backend:
  - Account entity (code, name, accountType: ASSET/LIABILITY/EQUITY/
    REVENUE/EXPENSE, description, active)
  - AccountJpaRepository + AccountService (list, findById, findByCode,
    create with duplicate-code guard)
  - AccountController at /api/v1/finance/accounts (GET list, GET by id,
    POST create). Permission-gated: finance.account.read, .create.
  - Liquibase 003-finance-accounts.xml: table + unique code index +
    6 seeded accounts (1000 Cash, 1100 AR, 1200 Inventory, 2100 AP,
    4100 Sales Revenue, 5100 COGS)
  - finance.yml updated: Account entity + 2 permissions + menu entry

SPA:
  - AccountsPage with sortable list + inline create form
  - finance.listAccounts + finance.createAccount in typed API client
  - Sidebar: "Chart of Accounts" above "Journal Entries" in Finance
  - Route /accounts wired in App.tsx + SpaController + SecurityConfig

This is the foundation for the next step (JournalEntryLine child
entity with per-account debit/credit legs + balanced-entry
validation). The seeded chart covers the 6 accounts the existing
event subscribers will reference once the double-entry lines land.
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -23,6 +23,7 @@
23 23 <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/>
24 24 <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/>
25 25 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
  26 + <include file="classpath:db/changelog/pbc-finance/003-finance-accounts.xml"/>
26 27 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
27 28 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
28 29 <include file="classpath:db/changelog/pbc-production/003-production-v3.xml"/>
... ...
distribution/src/main/resources/db/changelog/pbc-finance/003-finance-accounts.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-finance v0.30 — Chart of accounts.
  9 +
  10 + Adds the finance__account table and seeds a minimal 6-account
  11 + chart covering the accounts the event subscribers reference:
  12 + AR, AP, Revenue, COGS, Inventory, Cash.
  13 +
  14 + Operators can add accounts via POST /api/v1/finance/accounts
  15 + or through the SPA. The seeded set is NOT a full chart — it's
  16 + the minimum needed for the existing AR/AP event-driven journal
  17 + entries to reference real accounts. A richer default chart
  18 + (sub-accounts, account groups) is a future enhancement.
  19 + -->
  20 +
  21 + <changeSet id="finance-accounts-001" author="vibe_erp">
  22 + <comment>Create finance__account table</comment>
  23 + <sql>
  24 + CREATE TABLE finance__account (
  25 + id uuid PRIMARY KEY,
  26 + code varchar(32) NOT NULL,
  27 + name varchar(256) NOT NULL,
  28 + account_type varchar(16) NOT NULL,
  29 + description text,
  30 + active boolean NOT NULL DEFAULT true,
  31 + created_at timestamptz NOT NULL,
  32 + created_by varchar(128) NOT NULL,
  33 + updated_at timestamptz NOT NULL,
  34 + updated_by varchar(128) NOT NULL,
  35 + version bigint NOT NULL DEFAULT 0,
  36 + CONSTRAINT finance__account_type_check
  37 + CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'))
  38 + );
  39 + CREATE UNIQUE INDEX finance__account_code_uk
  40 + ON finance__account (code);
  41 + </sql>
  42 + <rollback>
  43 + DROP TABLE finance__account;
  44 + </rollback>
  45 + </changeSet>
  46 +
  47 + <changeSet id="finance-accounts-002-seed" author="vibe_erp">
  48 + <comment>Seed minimal chart of accounts</comment>
  49 + <sql>
  50 + INSERT INTO finance__account (id, code, name, account_type, description, created_at, created_by, updated_at, updated_by)
  51 + VALUES
  52 + (gen_random_uuid(), '1100', 'Accounts Receivable', 'ASSET', 'Money owed to the company by customers', now(), '__seed__', now(), '__seed__'),
  53 + (gen_random_uuid(), '1200', 'Inventory', 'ASSET', 'Value of goods held in warehouses', now(), '__seed__', now(), '__seed__'),
  54 + (gen_random_uuid(), '1000', 'Cash', 'ASSET', 'Cash and bank balances', now(), '__seed__', now(), '__seed__'),
  55 + (gen_random_uuid(), '2100', 'Accounts Payable', 'LIABILITY', 'Money the company owes to suppliers', now(), '__seed__', now(), '__seed__'),
  56 + (gen_random_uuid(), '4100', 'Sales Revenue', 'REVENUE', 'Income from sales orders', now(), '__seed__', now(), '__seed__'),
  57 + (gen_random_uuid(), '5100', 'Cost of Goods Sold', 'EXPENSE', 'Direct cost of goods delivered to customers', now(), '__seed__', now(), '__seed__');
  58 + </sql>
  59 + <rollback>
  60 + DELETE FROM finance__account WHERE created_by = '__seed__';
  61 + </rollback>
  62 + </changeSet>
  63 +
  64 +</databaseChangeLog>
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/AccountService.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.finance.domain.Account
  6 +import org.vibeerp.pbc.finance.domain.AccountType
  7 +import org.vibeerp.pbc.finance.infrastructure.AccountJpaRepository
  8 +import java.util.UUID
  9 +
  10 +@Service
  11 +@Transactional
  12 +class AccountService(
  13 + private val accounts: AccountJpaRepository,
  14 +) {
  15 +
  16 + @Transactional(readOnly = true)
  17 + fun list(): List<Account> = accounts.findAll()
  18 +
  19 + @Transactional(readOnly = true)
  20 + fun findById(id: UUID): Account? = accounts.findById(id).orElse(null)
  21 +
  22 + @Transactional(readOnly = true)
  23 + fun findByCode(code: String): Account? = accounts.findByCode(code)
  24 +
  25 + fun create(command: CreateAccountCommand): Account {
  26 + require(!accounts.existsByCode(command.code)) {
  27 + "account code '${command.code}' already exists"
  28 + }
  29 + return accounts.save(
  30 + Account(
  31 + code = command.code,
  32 + name = command.name,
  33 + accountType = command.accountType,
  34 + description = command.description,
  35 + ),
  36 + )
  37 + }
  38 +}
  39 +
  40 +data class CreateAccountCommand(
  41 + val code: String,
  42 + val name: String,
  43 + val accountType: AccountType,
  44 + val description: String? = null,
  45 +)
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/Account.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +
  10 +/**
  11 + * An account in the chart of accounts.
  12 + *
  13 + * The chart of accounts is the backbone of the GL — every journal
  14 + * entry line debits or credits one account. Accounts are typed by
  15 + * their normal balance direction:
  16 + *
  17 + * - **ASSET** — things the company owns (cash, inventory, AR)
  18 + * - **LIABILITY** — things the company owes (AP, loans)
  19 + * - **EQUITY** — owner's investment + retained earnings
  20 + * - **REVENUE** — income from operations (sales)
  21 + * - **EXPENSE** — costs incurred (COGS, wages)
  22 + *
  23 + * v1 seeds a minimal chart of accounts (6 accounts); operators can
  24 + * add accounts via the SPA or API. A richer chart with sub-accounts,
  25 + * account groups, and reporting categories is a future enhancement.
  26 + */
  27 +@Entity
  28 +@Table(name = "finance__account")
  29 +class Account(
  30 + code: String,
  31 + name: String,
  32 + accountType: AccountType,
  33 + description: String? = null,
  34 + active: Boolean = true,
  35 +) : AuditedJpaEntity() {
  36 +
  37 + @Column(name = "code", nullable = false, length = 32)
  38 + var code: String = code
  39 +
  40 + @Column(name = "name", nullable = false, length = 256)
  41 + var name: String = name
  42 +
  43 + @Enumerated(EnumType.STRING)
  44 + @Column(name = "account_type", nullable = false, length = 16)
  45 + var accountType: AccountType = accountType
  46 +
  47 + @Column(name = "description", nullable = true)
  48 + var description: String? = description
  49 +
  50 + @Column(name = "active", nullable = false)
  51 + var active: Boolean = active
  52 +
  53 + override fun toString(): String = "Account(id=$id, code='$code', type=$accountType)"
  54 +}
  55 +
  56 +enum class AccountType {
  57 + ASSET,
  58 + LIABILITY,
  59 + EQUITY,
  60 + REVENUE,
  61 + EXPENSE,
  62 +}
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/AccountController.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotNull
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.http.HttpStatus
  8 +import org.springframework.http.ResponseEntity
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PathVariable
  11 +import org.springframework.web.bind.annotation.PostMapping
  12 +import org.springframework.web.bind.annotation.RequestBody
  13 +import org.springframework.web.bind.annotation.RequestMapping
  14 +import org.springframework.web.bind.annotation.ResponseStatus
  15 +import org.springframework.web.bind.annotation.RestController
  16 +import org.vibeerp.pbc.finance.application.AccountService
  17 +import org.vibeerp.pbc.finance.application.CreateAccountCommand
  18 +import org.vibeerp.pbc.finance.domain.Account
  19 +import org.vibeerp.pbc.finance.domain.AccountType
  20 +import org.vibeerp.platform.security.authz.RequirePermission
  21 +import java.util.UUID
  22 +
  23 +@RestController
  24 +@RequestMapping("/api/v1/finance/accounts")
  25 +class AccountController(
  26 + private val accountService: AccountService,
  27 +) {
  28 +
  29 + @GetMapping
  30 + @RequirePermission("finance.account.read")
  31 + fun list(): List<AccountResponse> =
  32 + accountService.list().map { it.toResponse() }
  33 +
  34 + @GetMapping("/{id}")
  35 + @RequirePermission("finance.account.read")
  36 + fun get(@PathVariable id: UUID): ResponseEntity<AccountResponse> {
  37 + val account = accountService.findById(id) ?: return ResponseEntity.notFound().build()
  38 + return ResponseEntity.ok(account.toResponse())
  39 + }
  40 +
  41 + @PostMapping
  42 + @ResponseStatus(HttpStatus.CREATED)
  43 + @RequirePermission("finance.account.create")
  44 + fun create(@RequestBody @Valid request: CreateAccountRequest): AccountResponse =
  45 + accountService.create(
  46 + CreateAccountCommand(
  47 + code = request.code,
  48 + name = request.name,
  49 + accountType = request.accountType,
  50 + description = request.description,
  51 + ),
  52 + ).toResponse()
  53 +}
  54 +
  55 +data class CreateAccountRequest(
  56 + @field:NotBlank @field:Size(max = 32) val code: String,
  57 + @field:NotBlank @field:Size(max = 256) val name: String,
  58 + @field:NotNull val accountType: AccountType,
  59 + val description: String? = null,
  60 +)
  61 +
  62 +data class AccountResponse(
  63 + val id: UUID,
  64 + val code: String,
  65 + val name: String,
  66 + val accountType: AccountType,
  67 + val description: String?,
  68 + val active: Boolean,
  69 +)
  70 +
  71 +private fun Account.toResponse() = AccountResponse(
  72 + id = this.id,
  73 + code = this.code,
  74 + name = this.name,
  75 + accountType = this.accountType,
  76 + description = this.description,
  77 + active = this.active,
  78 +)
... ...
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/AccountJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.finance.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.finance.domain.Account
  6 +import org.vibeerp.pbc.finance.domain.AccountType
  7 +import java.util.UUID
  8 +
  9 +@Repository
  10 +interface AccountJpaRepository : JpaRepository<Account, UUID> {
  11 + fun findByCode(code: String): Account?
  12 + fun existsByCode(code: String): Boolean
  13 + fun findByAccountType(accountType: AccountType): List<Account>
  14 +}
... ...
pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml
1 1 # pbc-finance metadata.
2   -#
3   -# Loaded at boot by MetadataLoader, tagged source='core'. The minimal
4   -# v0.16 build only carries the JournalEntry entity, the read permission,
5   -# and a navigation menu entry. No write permissions exist because there
6   -# is no write endpoint — entries appear automatically in reaction to
7   -# domain events from other PBCs (SalesOrderConfirmed → AR row,
8   -# PurchaseOrderConfirmed → AP row).
9 2  
10 3 entities:
11 4 - name: JournalEntry
... ... @@ -13,13 +6,27 @@ entities:
13 6 table: finance__journal_entry
14 7 description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event
15 8  
  9 + - name: Account
  10 + pbc: finance
  11 + table: finance__account
  12 + description: An account in the chart of accounts (ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE)
  13 +
16 14 permissions:
17 15 - key: finance.journal.read
18 16 description: Read journal entries
  17 + - key: finance.account.read
  18 + description: Read chart of accounts
  19 + - key: finance.account.create
  20 + description: Create new accounts in the chart
19 21  
20 22 menus:
  23 + - path: /finance/accounts
  24 + label: Chart of Accounts
  25 + icon: calculator
  26 + section: Finance
  27 + order: 690
21 28 - path: /finance/journal-entries
22   - label: Journal entries
  29 + label: Journal Entries
23 30 icon: book-open
24 31 section: Finance
25 32 order: 700
... ...
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
... ... @@ -82,6 +82,7 @@ class SpaController {
82 82 "/shop-floor", "/shop-floor/", "/shop-floor/**",
83 83  
84 84 // Finance
  85 + "/accounts", "/accounts/", "/accounts/**",
85 86 "/journal-entries", "/journal-entries/", "/journal-entries/**",
86 87  
87 88 // System / identity
... ...
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
... ... @@ -109,6 +109,7 @@ class SecurityConfiguration {
109 109 "/purchase-orders", "/purchase-orders/**",
110 110 "/work-orders", "/work-orders/**",
111 111 "/shop-floor", "/shop-floor/**",
  112 + "/accounts", "/accounts/**",
112 113 "/journal-entries", "/journal-entries/**",
113 114 "/users", "/users/**",
114 115 "/roles", "/roles/**",
... ...
web/src/App.tsx
... ... @@ -37,6 +37,7 @@ import { WorkOrdersPage } from &#39;@/pages/WorkOrdersPage&#39;
37 37 import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage'
38 38 import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage'
39 39 import { ShopFloorPage } from '@/pages/ShopFloorPage'
  40 +import { AccountsPage } from '@/pages/AccountsPage'
40 41 import { JournalEntriesPage } from '@/pages/JournalEntriesPage'
41 42  
42 43 export default function App() {
... ... @@ -76,6 +77,7 @@ export default function App() {
76 77 <Route path="work-orders/new" element={<CreateWorkOrderPage />} />
77 78 <Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
78 79 <Route path="shop-floor" element={<ShopFloorPage />} />
  80 + <Route path="accounts" element={<AccountsPage />} />
79 81 <Route path="journal-entries" element={<JournalEntriesPage />} />
80 82 </Route>
81 83 <Route path="*" element={<Navigate to="/" replace />} />
... ...
web/src/api/client.ts
... ... @@ -25,6 +25,7 @@
25 25 // underlying TypeError, also caught by the page boundary.
26 26  
27 27 import type {
  28 + Account,
28 29 Item,
29 30 JournalEntry,
30 31 Location,
... ... @@ -276,6 +277,10 @@ export const production = {
276 277 // ─── Finance ─────────────────────────────────────────────────────────
277 278  
278 279 export const finance = {
  280 + listAccounts: () => apiFetch<Account[]>('/api/v1/finance/accounts'),
  281 + createAccount: (body: {
  282 + code: string; name: string; accountType: string; description?: string | null
  283 + }) => apiFetch<Account>('/api/v1/finance/accounts', { method: 'POST', body: JSON.stringify(body) }),
279 284 listJournalEntries: () =>
280 285 apiFetch<JournalEntry[]>('/api/v1/finance/journal-entries'),
281 286 }
... ...
web/src/layout/AppLayout.tsx
... ... @@ -63,7 +63,10 @@ const NAV: NavGroup[] = [
63 63 },
64 64 {
65 65 heading: 'Finance',
66   - items: [{ to: '/journal-entries', label: 'Journal Entries' }],
  66 + items: [
  67 + { to: '/accounts', label: 'Chart of Accounts' },
  68 + { to: '/journal-entries', label: 'Journal Entries' },
  69 + ],
67 70 },
68 71 {
69 72 heading: 'System',
... ...
web/src/pages/AccountsPage.tsx 0 → 100644
  1 +import { useEffect, useState, type FormEvent } from 'react'
  2 +import { finance } from '@/api/client'
  3 +import type { Account } from '@/types/api'
  4 +import { PageHeader } from '@/components/PageHeader'
  5 +import { Loading } from '@/components/Loading'
  6 +import { ErrorBox } from '@/components/ErrorBox'
  7 +import { DataTable, type Column } from '@/components/DataTable'
  8 +
  9 +const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const
  10 +
  11 +export function AccountsPage() {
  12 + const [rows, setRows] = useState<Account[]>([])
  13 + const [error, setError] = useState<Error | null>(null)
  14 + const [loading, setLoading] = useState(true)
  15 + const [showCreate, setShowCreate] = useState(false)
  16 + const [code, setCode] = useState('')
  17 + const [name, setName] = useState('')
  18 + const [accountType, setAccountType] = useState<string>('ASSET')
  19 + const [creating, setCreating] = useState(false)
  20 +
  21 + const load = () => {
  22 + finance
  23 + .listAccounts()
  24 + .then((rs) => setRows([...rs].sort((a, b) => a.code.localeCompare(b.code))))
  25 + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e))))
  26 + .finally(() => setLoading(false))
  27 + }
  28 +
  29 + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps
  30 +
  31 + const onCreate = async (e: FormEvent) => {
  32 + e.preventDefault()
  33 + setCreating(true)
  34 + setError(null)
  35 + try {
  36 + await finance.createAccount({ code, name, accountType })
  37 + setCode('')
  38 + setName('')
  39 + setShowCreate(false)
  40 + load()
  41 + } catch (err: unknown) {
  42 + setError(err instanceof Error ? err : new Error(String(err)))
  43 + } finally {
  44 + setCreating(false)
  45 + }
  46 + }
  47 +
  48 + const columns: Column<Account>[] = [
  49 + { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> },
  50 + { header: 'Name', key: 'name' },
  51 + { header: 'Type', key: 'accountType' },
  52 + { header: 'Description', key: 'description', render: (r) => r.description ?? '—' },
  53 + ]
  54 +
  55 + return (
  56 + <div>
  57 + <PageHeader
  58 + title="Chart of Accounts"
  59 + subtitle="GL accounts that journal entries debit and credit. 6 accounts seeded by default."
  60 + actions={
  61 + <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}>
  62 + {showCreate ? 'Cancel' : '+ New Account'}
  63 + </button>
  64 + }
  65 + />
  66 + {showCreate && (
  67 + <form onSubmit={onCreate} className="card p-4 mb-4 max-w-2xl flex flex-wrap items-end gap-3">
  68 + <div className="w-24">
  69 + <label className="block text-xs font-medium text-slate-700">Code</label>
  70 + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)}
  71 + placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  72 + </div>
  73 + <div className="flex-1 min-w-[150px]">
  74 + <label className="block text-xs font-medium text-slate-700">Name</label>
  75 + <input type="text" required value={name} onChange={(e) => setName(e.target.value)}
  76 + placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" />
  77 + </div>
  78 + <div className="w-32">
  79 + <label className="block text-xs font-medium text-slate-700">Type</label>
  80 + <select value={accountType} onChange={(e) => setAccountType(e.target.value)}
  81 + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm">
  82 + {ACCOUNT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
  83 + </select>
  84 + </div>
  85 + <button type="submit" className="btn-primary" disabled={creating}>
  86 + {creating ? '...' : 'Create'}
  87 + </button>
  88 + </form>
  89 + )}
  90 + {loading && <Loading />}
  91 + {error && <ErrorBox error={error} />}
  92 + {!loading && !error && <DataTable rows={rows} columns={columns} />}
  93 + </div>
  94 + )
  95 +}
... ...
web/src/types/api.ts
... ... @@ -250,6 +250,17 @@ export interface ShopFloorEntry {
250 250  
251 251 // ─── Finance (pbc-finance) ───────────────────────────────────────────
252 252  
  253 +export type AccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE'
  254 +
  255 +export interface Account {
  256 + id: string
  257 + code: string
  258 + name: string
  259 + accountType: AccountType
  260 + description: string | null
  261 + active: boolean
  262 +}
  263 +
253 264 export type JournalEntryType = 'AR' | 'AP'
254 265 export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED'
255 266  
... ...