acct_type instanceof UserTypes ? $user->acct_type : (UserTypes::tryFrom($user->acct_type) ?? UserTypes::PUBLIC); } private function isBig3(UserTypes $t): bool { return in_array($t, [UserTypes::ULTIMATE, UserTypes::SUPER_OPERATOR, UserTypes::OPERATOR], true); } private function isStoreLevel(UserTypes $t): bool { return in_array($t, [UserTypes::STORE_OWNER, UserTypes::STORE_MANAGER], true); } /** IDs of stores the current user owns or manages (including descendants). */ private function userStoreIds(): array { $user = Auth::user(); $allowedUserIds = array_merge([$user->id], $user->getAllDescendants()->pluck('id')->toArray()); return Store::where(function ($q) use ($allowedUserIds) { $q->whereIn('owner_id', $allowedUserIds) ->orWhereIn('manager_id', $allowedUserIds) ->orWhereHas('managers', function ($mq) use ($allowedUserIds) { $mq->whereIn('user_id', $allowedUserIds); }); })->pluck('id')->toArray(); } /** Apply store_id scope to a query based on the caller's role. */ private function scopeAccounts($query, array $storeIds, bool $isBig3) { if ($isBig3) { return $query->whereNull('store_id'); } return $query->whereIn('store_id', $storeIds); } /** Check store-level access. Returns false if the accounting_store module is disabled. */ private function guardStore(): bool { return ModuleHelper::isEnabled('accounting_store'); } // ─── public endpoints ──────────────────────────────────────────────────── /** * Full Chart of Accounts tree. */ public function getAccountsTree(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $base = Account::whereNull('parent_id')->where('is_active', true); $base = $this->scopeAccounts($base, $storeIds, $isBig3); $accounts = $base ->with(['children' => function ($q) use ($storeIds, $isBig3) { $q = $q->where('is_active', true); $q = $this->scopeAccounts($q, $storeIds, $isBig3); $q->with(['children' => function ($q2) use ($storeIds, $isBig3) { $q2 = $q2->where('is_active', true); $q2 = $this->scopeAccounts($q2, $storeIds, $isBig3); }]); }]) ->orderBy('id') ->get(); return response()->json(['success' => true, 'data' => $accounts]); } /** * Leaf accounts for the Daily Entry form. */ public function getLeafAccounts(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $base = Account::where('is_active', true)->whereDoesntHave('children'); $base = $this->scopeAccounts($base, $storeIds, $isBig3); $leafAccounts = $base ->with('parent.parent') ->orderBy('id') ->get(); return response()->json(['success' => true, 'data' => $leafAccounts]); } /** * Daily transactions for a given date. */ public function getDailyTransactions(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $date = $request->input('date', date('Y-m-d')); $accountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id'); $transactions = AccountTransaction::with(['account']) ->whereIn('account_id', $accountIds) ->whereDate('transaction_date', $date) ->get(); $mapped = []; foreach ($transactions as $txn) { $mapped[$txn->account_id] = [ 'id' => $txn->id, 'amount' => $txn->amount, 'notes' => $txn->notes, ]; } return response()->json(['success' => true, 'date' => $date, 'data' => $mapped]); } /** * Bulk save daily transactions. */ public function saveDailyTransactions(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $date = $request->input('date'); $entries = $request->input('entries', []); $userId = Auth::id(); // Allowed account IDs for this user (prevent writing to other stores' accounts) $allowedAccountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id')->flip()->all(); DB::beginTransaction(); try { foreach ($entries as $accountId => $entryData) { if (!array_key_exists($accountId, $allowedAccountIds)) { continue; // skip accounts not owned by this user } $amount = (float)($entryData['amount'] ?? 0); $account = Account::find($accountId); if (!$account) { continue; } $flow = $account->default_flow ?: ((strtoupper((string) $account->type) === 'REVENUE' || strtoupper((string) $account->type) === 'LIABILITY') ? 'INCOME' : 'EXPENSE'); $txn = AccountTransaction::where('account_id', $accountId) ->whereDate('transaction_date', $date) ->first(); if ($amount == 0) { if ($txn) { $txn->delete(); } continue; } if ($txn) { $txn->amount = $amount; $txn->notes = $entryData['notes'] ?? null; $txn->updated_by = $userId; $txn->save(); } else { AccountTransaction::create([ 'hashkey' => Str::random(32), 'account_id' => $accountId, 'amount' => $amount, 'flow' => $flow, 'transaction_date' => $date, 'notes' => $entryData['notes'] ?? null, 'created_by' => $userId, 'updated_by' => $userId, ]); } } DB::commit(); return response()->json(['success' => true, 'message' => 'Daily transactions saved.']); } catch (\Exception $e) { DB::rollBack(); return response()->json(['success' => false, 'message' => 'Error saving: ' . $e->getMessage()]); } } /** * Transaction list for CRUD tab. */ public function listTransactions(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $accountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id'); $query = AccountTransaction::with(['account', 'creator', 'updater']) ->whereIn('account_id', $accountIds); if ($request->filled('account_id')) { $query->where('account_id', $request->account_id); } if ($request->filled('date_from')) { $query->whereDate('transaction_date', '>=', $request->date_from); } if ($request->filled('date_to')) { $query->whereDate('transaction_date', '<=', $request->date_to); } $transactions = $query->orderBy('transaction_date', 'desc')->paginate(50); return response()->json(['success' => true, 'data' => $transactions]); } /** * Delete single transaction. */ public function deleteTransaction(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); $storeIds = $isBig3 ? [] : $this->userStoreIds(); $allowedAccountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id')->all(); $txn = AccountTransaction::find($request->id); if ($txn && in_array($txn->account_id, $allowedAccountIds)) { $txn->delete(); } return response()->json(['success' => true]); } /** * Monthly matrix report (Rows = Days, Columns = Accounts). */ public function getMonthlyReport(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $year = $request->input('year', date('Y')); $month = $request->input('month', date('m')); $accountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id')->all(); $transactions = AccountTransaction::whereYear('transaction_date', $year) ->whereMonth('transaction_date', $month) ->whereIn('account_id', $accountIds) ->get(); $daysInMonth = cal_days_in_month(CAL_GREGORIAN, (int)$month, (int)$year); $matrix = []; for ($i = 1; $i <= $daysInMonth; $i++) { $matrix[$i] = []; } foreach ($transactions as $txn) { $day = (int)date('d', strtotime($txn->transaction_date)); $matrix[$day][$txn->account_id] = ($matrix[$day][$txn->account_id] ?? 0) + $txn->amount; } $columnTotals = []; foreach ($transactions as $txn) { $columnTotals[$txn->account_id] = ($columnTotals[$txn->account_id] ?? 0) + $txn->amount; } return response()->json([ 'success' => true, 'year' => $year, 'month' => $month, 'days_in_month' => $daysInMonth, 'matrix' => $matrix, 'column_totals' => $columnTotals, ]); } /** * Create a new account node. For store-level users the account is * automatically scoped to their store (first owned store). */ public function createAccount(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $name = trim((string) $request->input('name', '')); $accountType = strtoupper(trim((string) $request->input('type', ''))); $defaultFlow = strtoupper(trim((string) $request->input('default_flow', ''))); $parentId = $request->input('parent_id') ?: null; $description = $request->input('description') ?: null; if ($name === '' || $accountType === '') { return response()->json(['success' => false, 'message' => 'Name and type are required.'], 422); } if (!in_array($defaultFlow, ['INCOME', 'EXPENSE'], true)) { $defaultFlow = in_array($accountType, ['REVENUE', 'LIABILITY']) ? 'INCOME' : 'EXPENSE'; } if ($parentId !== null && !Account::where('id', $parentId)->exists()) { return response()->json(['success' => false, 'message' => 'Parent account not found.'], 422); } // Determine store_id: null for Big3 (global), first store for store-level users $storeId = null; if (!$isBig3) { $storeIds = $this->userStoreIds(); if (empty($storeIds)) { return response()->json(['success' => false, 'message' => 'No store found for your account. Create a store first.'], 422); } // If a parent is supplied, inherit its store_id; otherwise use the request-supplied store_id or the user's first store if ($parentId) { $parent = Account::find($parentId); $storeId = $parent?->store_id ?? $storeIds[0]; } else { $requestedStoreId = $request->input('store_id'); $storeId = ($requestedStoreId && in_array($requestedStoreId, $storeIds)) ? (int)$requestedStoreId : $storeIds[0]; } } try { $account = Account::create([ 'hashkey' => Str::random(40), 'parent_id' => $parentId, 'store_id' => $storeId, 'type' => $accountType, 'default_flow' => $defaultFlow, 'name' => $name, 'description' => $description, 'is_active' => true, 'created_by' => Auth::id(), 'updated_by' => Auth::id(), ]); } catch (\Throwable $e) { \Hypervel\Support\Facades\Log::error('createAccount failed', [ 'message' => $e->getMessage(), 'name' => $name, 'type' => $accountType, 'parent_id' => $parentId, 'store_id' => $storeId, 'user_id' => Auth::id(), ]); return response()->json([ 'success' => false, 'message' => 'Could not create account: ' . $e->getMessage(), ], 422); } return response()->json(['success' => true, 'data' => $account]); } /** * Update editable fields (name, type, default_flow, description). */ public function updateAccount(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $account = Account::find($request->input('id')); if (!$account) { return response()->json(['success' => false, 'message' => 'Account not found.'], 404); } // Store-level users may only edit their own store's accounts $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3) { if (!$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $this->userStoreIds(); if (!in_array($account->store_id, $storeIds)) { return ResponseHelper::returnUnauthorized(); } } if ($request->filled('name')) { $account->name = trim((string) $request->input('name')); } if ($request->filled('type')) { $account->type = strtoupper(trim((string) $request->input('type'))); } if ($request->filled('default_flow')) { $flow = strtoupper(trim((string) $request->input('default_flow'))); if (!in_array($flow, ['INCOME', 'EXPENSE'], true)) { return response()->json(['success' => false, 'message' => 'default_flow must be INCOME or EXPENSE.'], 422); } $account->default_flow = $flow; } if ($request->has('description')) { $account->description = $request->input('description') ?: null; } $account->updated_by = Auth::id(); $account->save(); return response()->json(['success' => true, 'data' => $account]); } /** * Archive (soft-disable) an account. */ public function archiveAccount(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $account = Account::find($request->input('id')); if (!$account) { return response()->json(['success' => false, 'message' => 'Account not found.'], 404); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3) { if (!$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $this->userStoreIds(); if (!in_array($account->store_id, $storeIds)) { return ResponseHelper::returnUnauthorized(); } } if (Account::where('parent_id', $account->id)->where('is_active', true)->exists()) { return response()->json(['success' => false, 'message' => 'Archive child accounts first.'], 422); } if (AccountTransaction::where('account_id', $account->id)->exists()) { return response()->json(['success' => false, 'message' => 'Cannot archive: account has transactions. Move or delete them first.'], 422); } $account->is_active = false; $account->updated_by = Auth::id(); $account->save(); return response()->json(['success' => true]); } /** * Move an account under a new parent (or to root). Refuses cycles. */ public function moveAccount(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $account = Account::find($request->input('id')); if (!$account) { return response()->json(['success' => false, 'message' => 'Account not found.'], 404); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3) { if (!$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $this->userStoreIds(); if (!in_array($account->store_id, $storeIds)) { return ResponseHelper::returnUnauthorized(); } } $newParentId = $request->input('parent_id'); $newParentId = ($newParentId === null || $newParentId === '' || $newParentId == 0) ? null : (int)$newParentId; if ($newParentId === (int)$account->id) { return response()->json(['success' => false, 'message' => 'An account cannot be its own parent.'], 422); } if ($newParentId !== null) { $parent = Account::find($newParentId); if (!$parent) { return response()->json(['success' => false, 'message' => 'Target parent not found.'], 422); } $cursor = $parent; $hops = 0; while ($cursor && $hops < 1000) { if ((int)$cursor->id === (int)$account->id) { return response()->json(['success' => false, 'message' => 'Cannot move an account under one of its own descendants.'], 422); } $cursor = $cursor->parent_id ? Account::find($cursor->parent_id) : null; $hops++; } } $account->parent_id = $newParentId; $account->updated_by = Auth::id(); $account->save(); return response()->json(['success' => true]); } /** * Re-activate an archived account. */ public function restoreAccount(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $account = Account::find($request->input('id')); if (!$account) { return response()->json(['success' => false, 'message' => 'Account not found.'], 404); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3) { if (!$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $this->userStoreIds(); if (!in_array($account->store_id, $storeIds)) { return ResponseHelper::returnUnauthorized(); } } $account->is_active = true; $account->updated_by = Auth::id(); $account->save(); return response()->json(['success' => true]); } /** * Theme metadata + drift summary for Manage Accounts. */ public function getThemeInfo(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $current = \App\Support\AccountingTheme::current(); $definition = \App\Support\AccountingTheme::definition($current); return response()->json([ 'success' => true, 'current' => $current, 'definition' => [ 'label' => $definition['label'] ?? $current, 'description' => $definition['description'] ?? null, 'version' => $definition['version'] ?? 1, ], 'options' => \App\Support\AccountingTheme::options(), 'counts' => [ 'theme_tagged' => Account::where('theme_key', $current)->count(), 'user_added' => Account::whereNull('theme_key')->count(), 'archived' => Account::where('is_active', false)->count(), ], ]); } /** * Read-only drift report. */ public function getThemeDrift(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } return response()->json([ 'success' => true, 'data' => \App\Support\AccountingTheme::drift(), ]); } /** * Switch the active accounting theme. */ public function setTheme(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } $key = trim((string) $request->input('key', '')); if ($key === '' || !\array_key_exists($key, \App\Support\AccountingTheme::all())) { return response()->json(['success' => false, 'message' => 'Unknown theme.'], 422); } \App\Models\SystemSetting::setValue('accounting_theme', $key, 'accounting', 'string'); return response()->json(['success' => true, 'key' => $key]); } /** * Idempotently re-apply the active theme (additive only). */ public function applyTheme(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ManageAccounting)) { return ResponseHelper::returnUnauthorized(); } try { $stats = \App\Support\AccountingTheme::apply(); } catch (\Throwable $e) { return response()->json([ 'success' => false, 'message' => 'Failed to apply theme: ' . $e->getMessage(), ], 422); } return response()->json([ 'success' => true, 'message' => sprintf( 'Theme "%s" applied: %d created, %d updated, %d unchanged.', $stats['theme_key'], $stats['created'], $stats['stamped'], $stats['skipped'] ), 'data' => $stats, ]); } /** * Recent transactions for the Reports page. */ public function listReports(Request $request) { if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::ViewAccountingReports)) { return ResponseHelper::returnUnauthorized(); } $type = $this->resolveType(); $isBig3 = $this->isBig3($type); if (!$isBig3 && !$this->guardStore()) { return ResponseHelper::returnUnauthorized(); } $storeIds = $isBig3 ? [] : $this->userStoreIds(); $accountIds = $this->scopeAccounts(Account::query(), $storeIds, $isBig3)->pluck('id'); $transactions = AccountTransaction::with(['account', 'creator']) ->whereIn('account_id', $accountIds) ->orderBy('transaction_date', 'desc') ->limit(100) ->get(); return response()->json(['success' => true, 'transactions' => $transactions]); } }