> */ public static function all(): array { $themes = Config::get('accounting.themes', []); return \is_array($themes) ? $themes : []; } /** * The definition for a single theme (or the active theme when no key given). * * @return array|null */ public static function definition(?string $key = null): ?array { $key = $key ?? self::current(); $themes = self::all(); return $themes[$key] ?? null; } /** * The starter Chart of Accounts hierarchy for a theme. * * @return array> */ public static function tree(?string $key = null): array { $definition = self::definition($key); $tree = $definition['tree'] ?? []; return \is_array($tree) ? $tree : []; } /** * Convert an account name to a stable code for the given theme. * Used so re-applying a theme can match existing seeded accounts even if * a user has renamed them. */ public static function accountCode(string $name): string { $slug = strtolower(trim($name)); $slug = preg_replace('/[^a-z0-9]+/', '_', $slug) ?? ''; return trim($slug, '_'); } /** * Flatten a theme tree into a list of definitions keyed by code. * * Each entry: ['code', 'name', 'type', 'default_flow', 'parent_code', 'description']. * * @return array> */ public static function flatten(?string $key = null): array { $flat = []; self::flattenWalk(self::tree($key), null, $flat); return $flat; } private static function flattenWalk(array $nodes, ?string $parentCode, array &$flat): void { foreach ($nodes as $node) { $name = (string) ($node['name'] ?? ''); if ($name === '') { continue; } $code = self::accountCode($name); $type = strtoupper((string) ($node['type'] ?? '')); $flat[$code] = [ 'code' => $code, 'name' => $name, 'type' => $type, 'default_flow' => $node['default_flow'] ?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE'), 'parent_code' => $parentCode, 'description' => $node['description'] ?? null, ]; if (!empty($node['children']) && \is_array($node['children'])) { self::flattenWalk($node['children'], $code, $flat); } } } /** * Compare the active theme against the DB. Pure read — never mutates. * * Returns: * - missing: codes in the theme but absent from DB (need apply) * - present: codes in both, name matches * - renamed: codes in both but name differs in DB (user-edited) * - user_added: account ids/names with theme_key NULL (custom additions) * * @return array */ public static function drift(?string $key = null): array { $key = $key ?? self::current(); $themeAccounts = self::flatten($key); $rows = Account::where('theme_key', $key) ->get(['id', 'name', 'theme_account_code', 'is_active', 'parent_id']) ->keyBy('theme_account_code'); $missing = []; $present = []; $renamed = []; foreach ($themeAccounts as $code => $def) { $row = $rows[$code] ?? null; if (!$row) { $missing[] = ['code' => $code, 'name' => $def['name']]; continue; } if ($row->name !== $def['name']) { $renamed[] = ['code' => $code, 'expected' => $def['name'], 'current' => $row->name, 'id' => $row->id]; } else { $present[] = ['code' => $code, 'name' => $def['name'], 'id' => $row->id]; } } $userAdded = Account::whereNull('theme_key') ->orderBy('id') ->get(['id', 'name', 'parent_id']) ->map(fn($r) => ['id' => $r->id, 'name' => $r->name, 'parent_id' => $r->parent_id]) ->all(); return [ 'theme_key' => $key, 'missing' => $missing, 'present' => $present, 'renamed' => $renamed, 'user_added' => $userAdded, 'totals' => [ 'expected' => \count($themeAccounts), 'missing' => \count($missing), 'present' => \count($present), 'renamed' => \count($renamed), 'user_added' => \count($userAdded), ], ]; } /** * Idempotently apply the active (or specified) theme to the DB. * * Never deletes, never renames user edits. For each theme node: * - if a row matches by (theme_key, theme_account_code), stamp/refresh metadata * - else if a row matches by (parent_id, name), back-stamp it * - else create a new row * * @return array{created:int, stamped:int, skipped:int, theme_key:string} */ public static function apply(?string $key = null): array { $key = $key ?? self::current(); $tree = self::tree($key); $stats = ['created' => 0, 'stamped' => 0, 'skipped' => 0, 'theme_key' => $key]; foreach ($tree as $node) { self::applyNode($node, null, $key, $stats); } return $stats; } private static function applyNode(array $node, ?int $parentId, string $themeKey, array &$stats): void { $name = (string) ($node['name'] ?? ''); if ($name === '') { return; } $code = self::accountCode($name); $type = strtoupper((string) ($node['type'] ?? '')); $defaultFlow = $node['default_flow'] ?? (($type === 'REVENUE' || $type === 'LIABILITY') ? 'INCOME' : 'EXPENSE'); $account = Account::where('theme_key', $themeKey) ->where('theme_account_code', $code) ->where(function ($q) use ($parentId) { if ($parentId === null) { $q->whereNull('parent_id'); } else { $q->where('parent_id', $parentId); } }) ->first(); if (!$account) { $account = Account::where('parent_id', $parentId) ->where('name', $name) ->first(); } if ($account) { $dirty = false; if ($account->theme_key !== $themeKey) { $account->theme_key = $themeKey; $dirty = true; } if ($account->theme_account_code !== $code) { $account->theme_account_code = $code; $dirty = true; } if (empty($account->default_flow)) { $account->default_flow = $defaultFlow; $dirty = true; } if (empty($account->description) && !empty($node['description'])) { $account->description = $node['description']; $dirty = true; } if ($dirty) { $account->save(); $stats['stamped']++; } else { $stats['skipped']++; } } else { $account = Account::create([ 'hashkey' => Str::random(40), 'parent_id' => $parentId, 'type' => $type, 'default_flow' => $defaultFlow, 'name' => $name, 'description' => $node['description'] ?? null, 'theme_key' => $themeKey, 'theme_account_code' => $code, 'is_active' => true, ]); $stats['created']++; } if (!empty($node['children']) && \is_array($node['children'])) { foreach ($node['children'] as $child) { self::applyNode($child, $account->id, $themeKey, $stats); } } } /** * Lightweight summary of all themes for admin UI dropdowns. * * @return array> */ public static function options(): array { $options = []; foreach (self::all() as $key => $def) { $options[] = [ 'key' => $key, 'label' => $def['label'] ?? $key, 'description' => $def['description'] ?? null, 'version' => $def['version'] ?? 1, ]; } return $options; } }