diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml index b68a129..d84ea5f 100644 --- a/distribution/src/main/resources/db/changelog/master.xml +++ b/distribution/src/main/resources/db/changelog/master.xml @@ -23,6 +23,7 @@ + diff --git a/distribution/src/main/resources/db/changelog/pbc-finance/003-finance-accounts.xml b/distribution/src/main/resources/db/changelog/pbc-finance/003-finance-accounts.xml new file mode 100644 index 0000000..6d98e3f --- /dev/null +++ b/distribution/src/main/resources/db/changelog/pbc-finance/003-finance-accounts.xml @@ -0,0 +1,64 @@ + + + + + + + Create finance__account table + + CREATE TABLE finance__account ( + id uuid PRIMARY KEY, + code varchar(32) NOT NULL, + name varchar(256) NOT NULL, + account_type varchar(16) NOT NULL, + description text, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL, + created_by varchar(128) NOT NULL, + updated_at timestamptz NOT NULL, + updated_by varchar(128) NOT NULL, + version bigint NOT NULL DEFAULT 0, + CONSTRAINT finance__account_type_check + CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE')) + ); + CREATE UNIQUE INDEX finance__account_code_uk + ON finance__account (code); + + + DROP TABLE finance__account; + + + + + Seed minimal chart of accounts + + INSERT INTO finance__account (id, code, name, account_type, description, created_at, created_by, updated_at, updated_by) + VALUES + (gen_random_uuid(), '1100', 'Accounts Receivable', 'ASSET', 'Money owed to the company by customers', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), '1200', 'Inventory', 'ASSET', 'Value of goods held in warehouses', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), '1000', 'Cash', 'ASSET', 'Cash and bank balances', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), '2100', 'Accounts Payable', 'LIABILITY', 'Money the company owes to suppliers', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), '4100', 'Sales Revenue', 'REVENUE', 'Income from sales orders', now(), '__seed__', now(), '__seed__'), + (gen_random_uuid(), '5100', 'Cost of Goods Sold', 'EXPENSE', 'Direct cost of goods delivered to customers', now(), '__seed__', now(), '__seed__'); + + + DELETE FROM finance__account WHERE created_by = '__seed__'; + + + + diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/AccountService.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/AccountService.kt new file mode 100644 index 0000000..7d4c56e --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/AccountService.kt @@ -0,0 +1,45 @@ +package org.vibeerp.pbc.finance.application + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.vibeerp.pbc.finance.domain.Account +import org.vibeerp.pbc.finance.domain.AccountType +import org.vibeerp.pbc.finance.infrastructure.AccountJpaRepository +import java.util.UUID + +@Service +@Transactional +class AccountService( + private val accounts: AccountJpaRepository, +) { + + @Transactional(readOnly = true) + fun list(): List = accounts.findAll() + + @Transactional(readOnly = true) + fun findById(id: UUID): Account? = accounts.findById(id).orElse(null) + + @Transactional(readOnly = true) + fun findByCode(code: String): Account? = accounts.findByCode(code) + + fun create(command: CreateAccountCommand): Account { + require(!accounts.existsByCode(command.code)) { + "account code '${command.code}' already exists" + } + return accounts.save( + Account( + code = command.code, + name = command.name, + accountType = command.accountType, + description = command.description, + ), + ) + } +} + +data class CreateAccountCommand( + val code: String, + val name: String, + val accountType: AccountType, + val description: String? = null, +) diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/Account.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/Account.kt new file mode 100644 index 0000000..c239454 --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/Account.kt @@ -0,0 +1,62 @@ +package org.vibeerp.pbc.finance.domain + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity + +/** + * An account in the chart of accounts. + * + * The chart of accounts is the backbone of the GL — every journal + * entry line debits or credits one account. Accounts are typed by + * their normal balance direction: + * + * - **ASSET** — things the company owns (cash, inventory, AR) + * - **LIABILITY** — things the company owes (AP, loans) + * - **EQUITY** — owner's investment + retained earnings + * - **REVENUE** — income from operations (sales) + * - **EXPENSE** — costs incurred (COGS, wages) + * + * v1 seeds a minimal chart of accounts (6 accounts); operators can + * add accounts via the SPA or API. A richer chart with sub-accounts, + * account groups, and reporting categories is a future enhancement. + */ +@Entity +@Table(name = "finance__account") +class Account( + code: String, + name: String, + accountType: AccountType, + description: String? = null, + active: Boolean = true, +) : AuditedJpaEntity() { + + @Column(name = "code", nullable = false, length = 32) + var code: String = code + + @Column(name = "name", nullable = false, length = 256) + var name: String = name + + @Enumerated(EnumType.STRING) + @Column(name = "account_type", nullable = false, length = 16) + var accountType: AccountType = accountType + + @Column(name = "description", nullable = true) + var description: String? = description + + @Column(name = "active", nullable = false) + var active: Boolean = active + + override fun toString(): String = "Account(id=$id, code='$code', type=$accountType)" +} + +enum class AccountType { + ASSET, + LIABILITY, + EQUITY, + REVENUE, + EXPENSE, +} diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/AccountController.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/AccountController.kt new file mode 100644 index 0000000..b5ef51a --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/AccountController.kt @@ -0,0 +1,78 @@ +package org.vibeerp.pbc.finance.http + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.vibeerp.pbc.finance.application.AccountService +import org.vibeerp.pbc.finance.application.CreateAccountCommand +import org.vibeerp.pbc.finance.domain.Account +import org.vibeerp.pbc.finance.domain.AccountType +import org.vibeerp.platform.security.authz.RequirePermission +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/finance/accounts") +class AccountController( + private val accountService: AccountService, +) { + + @GetMapping + @RequirePermission("finance.account.read") + fun list(): List = + accountService.list().map { it.toResponse() } + + @GetMapping("/{id}") + @RequirePermission("finance.account.read") + fun get(@PathVariable id: UUID): ResponseEntity { + val account = accountService.findById(id) ?: return ResponseEntity.notFound().build() + return ResponseEntity.ok(account.toResponse()) + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @RequirePermission("finance.account.create") + fun create(@RequestBody @Valid request: CreateAccountRequest): AccountResponse = + accountService.create( + CreateAccountCommand( + code = request.code, + name = request.name, + accountType = request.accountType, + description = request.description, + ), + ).toResponse() +} + +data class CreateAccountRequest( + @field:NotBlank @field:Size(max = 32) val code: String, + @field:NotBlank @field:Size(max = 256) val name: String, + @field:NotNull val accountType: AccountType, + val description: String? = null, +) + +data class AccountResponse( + val id: UUID, + val code: String, + val name: String, + val accountType: AccountType, + val description: String?, + val active: Boolean, +) + +private fun Account.toResponse() = AccountResponse( + id = this.id, + code = this.code, + name = this.name, + accountType = this.accountType, + description = this.description, + active = this.active, +) diff --git a/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/AccountJpaRepository.kt b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/AccountJpaRepository.kt new file mode 100644 index 0000000..fcd7d4c --- /dev/null +++ b/pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/AccountJpaRepository.kt @@ -0,0 +1,14 @@ +package org.vibeerp.pbc.finance.infrastructure + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import org.vibeerp.pbc.finance.domain.Account +import org.vibeerp.pbc.finance.domain.AccountType +import java.util.UUID + +@Repository +interface AccountJpaRepository : JpaRepository { + fun findByCode(code: String): Account? + fun existsByCode(code: String): Boolean + fun findByAccountType(accountType: AccountType): List +} diff --git a/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml b/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml index 6e9b5e0..b546aff 100644 --- a/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml +++ b/pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml @@ -1,11 +1,4 @@ # pbc-finance metadata. -# -# Loaded at boot by MetadataLoader, tagged source='core'. The minimal -# v0.16 build only carries the JournalEntry entity, the read permission, -# and a navigation menu entry. No write permissions exist because there -# is no write endpoint — entries appear automatically in reaction to -# domain events from other PBCs (SalesOrderConfirmed → AR row, -# PurchaseOrderConfirmed → AP row). entities: - name: JournalEntry @@ -13,13 +6,27 @@ entities: table: finance__journal_entry description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event + - name: Account + pbc: finance + table: finance__account + description: An account in the chart of accounts (ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE) + permissions: - key: finance.journal.read description: Read journal entries + - key: finance.account.read + description: Read chart of accounts + - key: finance.account.create + description: Create new accounts in the chart menus: + - path: /finance/accounts + label: Chart of Accounts + icon: calculator + section: Finance + order: 690 - path: /finance/journal-entries - label: Journal entries + label: Journal Entries icon: book-open section: Finance order: 700 diff --git a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt index 354b143..f0a48ad 100644 --- a/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt +++ b/platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt @@ -82,6 +82,7 @@ class SpaController { "/shop-floor", "/shop-floor/", "/shop-floor/**", // Finance + "/accounts", "/accounts/", "/accounts/**", "/journal-entries", "/journal-entries/", "/journal-entries/**", // System / identity diff --git a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt index 7cb3846..728f6c5 100644 --- a/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt +++ b/platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt @@ -109,6 +109,7 @@ class SecurityConfiguration { "/purchase-orders", "/purchase-orders/**", "/work-orders", "/work-orders/**", "/shop-floor", "/shop-floor/**", + "/accounts", "/accounts/**", "/journal-entries", "/journal-entries/**", "/users", "/users/**", "/roles", "/roles/**", diff --git a/web/src/App.tsx b/web/src/App.tsx index 2d671ea..4393da5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -37,6 +37,7 @@ import { WorkOrdersPage } from '@/pages/WorkOrdersPage' import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' import { ShopFloorPage } from '@/pages/ShopFloorPage' +import { AccountsPage } from '@/pages/AccountsPage' import { JournalEntriesPage } from '@/pages/JournalEntriesPage' export default function App() { @@ -76,6 +77,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6c5272b..4778b9c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -25,6 +25,7 @@ // underlying TypeError, also caught by the page boundary. import type { + Account, Item, JournalEntry, Location, @@ -276,6 +277,10 @@ export const production = { // ─── Finance ───────────────────────────────────────────────────────── export const finance = { + listAccounts: () => apiFetch('/api/v1/finance/accounts'), + createAccount: (body: { + code: string; name: string; accountType: string; description?: string | null + }) => apiFetch('/api/v1/finance/accounts', { method: 'POST', body: JSON.stringify(body) }), listJournalEntries: () => apiFetch('/api/v1/finance/journal-entries'), } diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 237390e..84f1c40 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -63,7 +63,10 @@ const NAV: NavGroup[] = [ }, { heading: 'Finance', - items: [{ to: '/journal-entries', label: 'Journal Entries' }], + items: [ + { to: '/accounts', label: 'Chart of Accounts' }, + { to: '/journal-entries', label: 'Journal Entries' }, + ], }, { heading: 'System', diff --git a/web/src/pages/AccountsPage.tsx b/web/src/pages/AccountsPage.tsx new file mode 100644 index 0000000..754c495 --- /dev/null +++ b/web/src/pages/AccountsPage.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { finance } from '@/api/client' +import type { Account } from '@/types/api' +import { PageHeader } from '@/components/PageHeader' +import { Loading } from '@/components/Loading' +import { ErrorBox } from '@/components/ErrorBox' +import { DataTable, type Column } from '@/components/DataTable' + +const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const + +export function AccountsPage() { + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [showCreate, setShowCreate] = useState(false) + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [accountType, setAccountType] = useState('ASSET') + const [creating, setCreating] = useState(false) + + const load = () => { + finance + .listAccounts() + .then((rs) => setRows([...rs].sort((a, b) => a.code.localeCompare(b.code)))) + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) + .finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const onCreate = async (e: FormEvent) => { + e.preventDefault() + setCreating(true) + setError(null) + try { + await finance.createAccount({ code, name, accountType }) + setCode('') + setName('') + setShowCreate(false) + load() + } catch (err: unknown) { + setError(err instanceof Error ? err : new Error(String(err))) + } finally { + setCreating(false) + } + } + + const columns: Column[] = [ + { header: 'Code', key: 'code', render: (r) => {r.code} }, + { header: 'Name', key: 'name' }, + { header: 'Type', key: 'accountType' }, + { header: 'Description', key: 'description', render: (r) => r.description ?? '—' }, + ] + + return ( +
+ setShowCreate(!showCreate)}> + {showCreate ? 'Cancel' : '+ New Account'} + + } + /> + {showCreate && ( +
+
+ + setCode(e.target.value)} + placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> +
+
+ + setName(e.target.value)} + placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> +
+
+ + +
+ +
+ )} + {loading && } + {error && } + {!loading && !error && } +
+ ) +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index a05db2b..32c13fe 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -250,6 +250,17 @@ export interface ShopFloorEntry { // ─── Finance (pbc-finance) ─────────────────────────────────────────── +export type AccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE' + +export interface Account { + id: string + code: string + name: string + accountType: AccountType + description: string | null + active: boolean +} + export type JournalEntryType = 'AR' | 'AP' export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED'