initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,751 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Accounting;
use App\Http\Controllers\AbstractController;
use App\Models\Accounting\Account;
use App\Models\Accounting\AccountTransaction;
use App\Models\Market\Store;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use App\Enums\UserTypes;
use App\Http\Controllers\Helpers\ResponseHelper;
use App\Support\ModuleHelper;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
use Hypervel\Support\Facades\DB;
use Hyperf\Stringable\Str;
class AccountingController extends AbstractController
{
// ─── helpers ────────────────────────────────────────────────────────────
private function resolveType(): UserTypes
{
$user = Auth::user();
return $user->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]);
}
}