diff --git a/app/Domains/Reporting/Http/Controllers/Company/DashboardController.php b/app/Domains/Reporting/Http/Controllers/Company/DashboardController.php new file mode 100644 index 00000000..ae26fe61 --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/Company/DashboardController.php @@ -0,0 +1,200 @@ +header('company'); + + $this->authorize('view dashboard', Company::find($companyId)); + + $openingMonth = intval(explode('-', CompanySetting::getSetting('fiscal_year', $companyId))[0]); + + // Three cursors over the same starting instant: the fixed left edge of + // the whole window, and the pair that walks it a month at a time. + $windowStart = Carbon::now(); + $monthStart = Carbon::now(); + $monthEnd = Carbon::now(); + + // A fiscal year whose opening month is still ahead in the calendar year + // is the one that opened twelve months ago. + $openedLastYear = $openingMonth > $monthStart->month; + + foreach ([$windowStart, $monthStart, $monthEnd] as $cursor) { + if ($openedLastYear) { + $cursor->subYear(); + } + + $cursor->month($openingMonth); + } + + $windowStart->startOfMonth(); + $monthStart->startOfMonth(); + $monthEnd->endOfMonth(); + + // The key's presence is the whole signal — its value is never read. + $previousYear = $request->has('previous_year'); + + if ($previousYear) { + $windowStart->subYear()->startOfMonth(); + $monthStart->subYear()->startOfMonth(); + $monthEnd->subYear()->endOfMonth(); + } + + $months = []; + $invoiceTotals = []; + $expenseTotals = []; + $receiptTotals = []; + $netIncomeTotals = []; + + for ($bucket = 0; $bucket < 12; $bucket++) { + $bucketSpan = [$monthStart->format('Y-m-d'), $monthEnd->format('Y-m-d')]; + + $invoiceTotals[] = Invoice::query() + ->whereBetween('invoice_date', $bucketSpan) + ->whereCompany() + ->sum('base_total'); + + $expenseTotals[] = Expense::query() + ->whereBetween('expense_date', $bucketSpan) + ->whereCompany() + ->sum('base_amount'); + + $receiptTotals[] = Payment::query() + ->whereBetween('payment_date', $bucketSpan) + ->whereCompany() + ->sum('base_amount'); + + // Net income is what came in less what went out. Invoiced money is + // not part of it — only money actually received counts. + $netIncomeTotals[] = $receiptTotals[$bucket] - $expenseTotals[$bucket]; + + $months[] = $monthStart->translatedFormat('M'); + + // Both cursors step forward off the first of their month, so a + // short month can never drag the walk backwards. + $monthEnd->startOfMonth()->addMonth()->endOfMonth(); + $monthStart->addMonth()->startOfMonth(); + } + + // Twelve steps left the walking cursor on the month after the window. + // Back it up on to the last month and take that month's final day as + // the right edge of the whole-window figures. + $monthStart->subMonth()->endOfMonth(); + + $windowSpan = [$windowStart->format('Y-m-d'), $monthStart->format('Y-m-d')]; + + $totalSales = Invoice::query() + ->whereBetween('invoice_date', $windowSpan) + ->whereCompany() + ->sum('base_total'); + + $totalReceipts = Payment::query() + ->whereBetween('payment_date', $windowSpan) + ->whereCompany() + ->sum('base_amount'); + + $totalExpenses = Expense::query() + ->whereBetween('expense_date', $windowSpan) + ->whereCompany() + ->sum('base_amount'); + + $totalNetIncome = (int) $totalReceipts - (int) $totalExpenses; + + $chartData = [ + 'months' => $months, + 'invoice_totals' => $invoiceTotals, + 'expense_totals' => $expenseTotals, + 'receipt_totals' => $receiptTotals, + 'net_income_totals' => $netIncomeTotals, + ]; + + $customerCount = Customer::query()->whereCompany()->count(); + + // "How many invoices did we issue" counts issued documents, so the + // reversals are left out. The money figures above deliberately keep + // them: a credit note's negated total is exactly what nets a sale back + // out. The outstanding sum below keeps them too, which is a quirk + // rather than a decision — a credit note's due amount is always zero, + // so it adds nothing, and the sum has always been taken over the lot. + $invoiceCount = Invoice::query() + ->whereCompany() + ->where('type', Invoice::TYPE_INVOICE) + ->count(); + + $estimateCount = Estimate::query()->whereCompany()->count(); + + $amountDue = Invoice::query() + ->whereCompany() + ->sum('base_due_amount'); + + // Raw models rather than InvoiceResource: each loaded relation is + // serialized with the full $appends set, so a column-limited + // creditNotes load blew up inside the date accessors (the children + // arrive without company_id) and loading them whole would run those + // appends per credit note for nothing. Neither list needs the relation + // anyway — credited_status is a resource-level field, and a fully + // credited invoice has no due amount left, so it never reaches here. + $recentDueInvoices = Invoice::with('customer') + ->whereCompany()->where('base_due_amount', '>', 0) + ->take(5) + ->latest() + ->get(); + + $recentEstimates = Estimate::with('customer') + ->whereCompany() + ->take(5) + ->latest() + ->get(); + + // Both lists are gated on the viewer's own document rights and come + // back empty — never absent — when those are missing. The counters and + // the money figures are not gated at all: holding the dashboard + // ability is enough to see company revenue. + return response()->json([ + 'total_amount_due' => $amountDue, + 'total_customer_count' => $customerCount, + 'total_invoice_count' => $invoiceCount, + 'total_estimate_count' => $estimateCount, + + 'recent_due_invoices' => BouncerFacade::can('view-invoice', Invoice::class) ? $recentDueInvoices : [], + 'recent_estimates' => BouncerFacade::can('view-estimate', Estimate::class) ? $recentEstimates : [], + + 'chart_data' => $chartData, + + 'total_sales' => $totalSales, + 'total_receipts' => $totalReceipts, + 'total_expenses' => $totalExpenses, + 'total_net_income' => $totalNetIncome, + ]); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/Company/SearchController.php b/app/Domains/Reporting/Http/Controllers/Company/SearchController.php new file mode 100644 index 00000000..8bb87077 --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/Company/SearchController.php @@ -0,0 +1,72 @@ +only(['search']); + + // The company narrowing is applied after the contact filters and + // before the member ones. The two orders are not interchangeable: a + // filter that contributes an `orWhere` at the top level widens + // whatever sits to its left, so the sequence is kept as it stands. + $customers = Customer::query() + ->applyFilters($term) + ->whereCompany() + ->latest() + ->paginate(10); + + $users = []; + + if ($request->user()->isOwner()) { + $users = User::query() + ->whereCompany() + ->applyFilters($term) + ->latest() + ->paginate(10); + } + + return response()->json([ + 'customers' => $customers, + 'users' => $users, + ]); + } + + /** + * Accounts whose email contains the given fragment. + * + * KNOWN DEFECT, reproduced deliberately: the lookup is not scoped to a + * company. It backs the invite flow, which has to be able to find an + * account that has no membership here yet, so it reads across the whole + * installation and discloses the existence and name of accounts belonging + * to other tenants. The only gate is the right to create a member. + */ + public function users(Request $request) + { + $this->authorize('create', User::class); + + return response()->json([ + 'users' => User::query() + ->whereEmail($request->email) + ->latest() + ->paginate(10), + ]); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php b/app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php new file mode 100644 index 00000000..d5265f18 --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php @@ -0,0 +1,142 @@ +reportedCompany($hash); + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + $window = $request->only(['from_date', 'to_date']); + + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date); + + $customers = Customer::query() + ->with(['invoices' => fn ($documents) => $documents->whereBetween( + 'invoice_date', + [$opened->format('Y-m-d'), $closed->format('Y-m-d')] + )]) + ->where('company_id', $company->id) + ->applyInvoiceFilters($window) + ->get(); + + $grandTotal = 0; + + $customers->each(function (Customer $customer) use (&$grandTotal): void { + $earned = $customer->invoices->sum('base_total'); + + $customer->totalAmount = $earned; + $grandTotal += $earned; + }); + + view()->share([ + 'customers' => $customers, + 'totalAmount' => $grandTotal, + ] + $this->pageChrome($request, $company)); + + return $this->emit($request, 'sales-customers'); + } + + /** + * The company named by the hash, once the caller has been let through. + * + * Nothing upstream tells Bouncer which company to weigh abilities against: + * these links carry no company header, and the report ability is stored + * per company, so the unscoped check matched nothing and every report + * answered 403. Pointing the scope at the company in the URL settles that + * without widening access, because the policy still asks for membership. + * The hash is an address, not a credential. + * + * @param string $hash + */ + private function reportedCompany($hash): Company + { + $company = Company::query()->where('unique_hash', $hash)->firstOrFail(); + + BouncerFacade::scope()->to($company->id); + + $this->authorize('view report', $company); + + return $company; + } + + /** + * What every report prints around its figures: the company and its logo, + * the window in the company's own date format, and the currency the + * amounts are stated in. + * + * @return array + */ + private function pageChrome(Request $request, Company $company): array + { + $pattern = CompanySetting::getSetting('carbon_date_format', $company->id); + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($pattern); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date)->translatedFormat($pattern); + $currencyId = CompanySetting::getSetting('currency', $company->id); + $currency = Currency::findOrFail($currencyId); + + return [ + 'company' => $company, + 'logo' => $company->logo_path, + 'from_date' => $opened, + 'to_date' => $closed, + 'currency' => $currency, + ]; + } + + /** + * Hand the rendered report over in whichever of the three shapes the query + * string asks for. + * + * Reports have no template chooser, so an override is a file of the same + * name dropped into storage/app/templates/pdf/reports/, which the resolver + * prefers over the built-in one. + * + * The document is built before the preview branch is taken and not after: + * a preview costs a full render it never uses, which is wasteful but is + * also what the templates have always been exercised through. + */ + private function emit(Request $request, string $design) + { + $design = PdfTemplateUtils::resolveView('reports', $design); + + $document = Pdf::loadView($design, [], PdfPageSetup::forReports()); + + if ($request->exists('preview')) { + return view($design); + } + + return $request->exists('download') ? $document->download() : $document->stream(); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/ExpensesReportController.php b/app/Domains/Reporting/Http/Controllers/ExpensesReportController.php new file mode 100644 index 00000000..9def158f --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/ExpensesReportController.php @@ -0,0 +1,142 @@ +reportedCompany($hash); + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + $spending = Expense::query() + ->with('category') + ->whereCompanyId($company->id) + ->applyFilters($request->only(['from_date', 'to_date', 'expense_category_id'])) + ->orderBy('expense_date', 'asc') + ->get(); + + $spentInTotal = $spending->sum('base_amount'); + + $buckets = $spending->groupBy( + fn (Expense $expense) => $expense->category ? $expense->category->name : trans('expenses.uncategorized') + ); + + $expenseGroups = collect(); + + foreach ($buckets as $heading => $bucket) { + $expenseGroups[] = [ + 'name' => $heading, + 'expenses' => $bucket, + 'total' => $bucket->sum('base_amount'), + ]; + } + + view()->share([ + 'expenseGroups' => $expenseGroups, + 'totalExpense' => $spentInTotal, + ] + $this->pageChrome($request, $company)); + + return $this->emit($request, 'expenses'); + } + + /** + * The company named by the hash, once the caller has been let through. + * + * Nothing upstream tells Bouncer which company to weigh abilities against: + * these links carry no company header, and the report ability is stored + * per company, so the unscoped check matched nothing and every report + * answered 403. Pointing the scope at the company in the URL settles that + * without widening access, because the policy still asks for membership. + * The hash is an address, not a credential. + * + * @param string $hash + */ + private function reportedCompany($hash): Company + { + $company = Company::query()->where('unique_hash', $hash)->firstOrFail(); + + BouncerFacade::scope()->to($company->id); + + $this->authorize('view report', $company); + + return $company; + } + + /** + * What every report prints around its figures: the company and its logo, + * the window in the company's own date format, and the currency the + * amounts are stated in. + * + * @return array + */ + private function pageChrome(Request $request, Company $company): array + { + $pattern = CompanySetting::getSetting('carbon_date_format', $company->id); + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($pattern); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date)->translatedFormat($pattern); + $currencyId = CompanySetting::getSetting('currency', $company->id); + $currency = Currency::findOrFail($currencyId); + + return [ + 'company' => $company, + 'logo' => $company->logo_path, + 'from_date' => $opened, + 'to_date' => $closed, + 'currency' => $currency, + ]; + } + + /** + * Hand the rendered report over in whichever of the three shapes the query + * string asks for. + * + * Reports have no template chooser, so an override is a file of the same + * name dropped into storage/app/templates/pdf/reports/, which the resolver + * prefers over the built-in one. + * + * The document is built before the preview branch is taken and not after: + * a preview costs a full render it never uses, which is wasteful but is + * also what the templates have always been exercised through. + */ + private function emit(Request $request, string $design) + { + $design = PdfTemplateUtils::resolveView('reports', $design); + + $document = Pdf::loadView($design, [], PdfPageSetup::forReports()); + + if ($request->exists('preview')) { + return view($design); + } + + return $request->exists('download') ? $document->download() : $document->stream(); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/ItemSalesReportController.php b/app/Domains/Reporting/Http/Controllers/ItemSalesReportController.php new file mode 100644 index 00000000..9a3dbaff --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/ItemSalesReportController.php @@ -0,0 +1,127 @@ +reportedCompany($hash); + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + $window = $request->only(['from_date', 'to_date']); + + $items = InvoiceItem::query() + ->whereCompany($company->id) + ->applyInvoiceFilters($window) + ->itemAttributes() + ->get(); + + view()->share([ + 'items' => $items, + 'totalAmount' => $items->sum('total_amount'), + ] + $this->pageChrome($request, $company)); + + return $this->emit($request, 'sales-items'); + } + + /** + * The company named by the hash, once the caller has been let through. + * + * Nothing upstream tells Bouncer which company to weigh abilities against: + * these links carry no company header, and the report ability is stored + * per company, so the unscoped check matched nothing and every report + * answered 403. Pointing the scope at the company in the URL settles that + * without widening access, because the policy still asks for membership. + * The hash is an address, not a credential. + * + * @param string $hash + */ + private function reportedCompany($hash): Company + { + $company = Company::query()->where('unique_hash', $hash)->firstOrFail(); + + BouncerFacade::scope()->to($company->id); + + $this->authorize('view report', $company); + + return $company; + } + + /** + * What every report prints around its figures: the company and its logo, + * the window in the company's own date format, and the currency the + * amounts are stated in. + * + * @return array + */ + private function pageChrome(Request $request, Company $company): array + { + $pattern = CompanySetting::getSetting('carbon_date_format', $company->id); + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($pattern); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date)->translatedFormat($pattern); + $currencyId = CompanySetting::getSetting('currency', $company->id); + $currency = Currency::findOrFail($currencyId); + + return [ + 'company' => $company, + 'logo' => $company->logo_path, + 'from_date' => $opened, + 'to_date' => $closed, + 'currency' => $currency, + ]; + } + + /** + * Hand the rendered report over in whichever of the three shapes the query + * string asks for. + * + * Reports have no template chooser, so an override is a file of the same + * name dropped into storage/app/templates/pdf/reports/, which the resolver + * prefers over the built-in one. + * + * The document is built before the preview branch is taken and not after: + * a preview costs a full render it never uses, which is wasteful but is + * also what the templates have always been exercised through. + */ + private function emit(Request $request, string $design) + { + $design = PdfTemplateUtils::resolveView('reports', $design); + + $document = Pdf::loadView($design, [], PdfPageSetup::forReports()); + + if ($request->exists('preview')) { + return view($design); + } + + return $request->exists('download') ? $document->download() : $document->stream(); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/ProfitLossReportController.php b/app/Domains/Reporting/Http/Controllers/ProfitLossReportController.php new file mode 100644 index 00000000..bc170812 --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/ProfitLossReportController.php @@ -0,0 +1,135 @@ +reportedCompany($hash); + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + $window = $request->only(['from_date', 'to_date']); + + $received = Payment::query() + ->whereCompanyId($company->id) + ->applyFilters($window) + ->sum('base_amount'); + + $spending = Expense::query() + ->with('category') + ->whereCompanyId($company->id) + ->applyFilters($window) + ->expensesAttributes() + ->get(); + + view()->share([ + 'income' => $received, + 'expenseCategories' => $spending, + 'totalExpense' => $spending->sum('total_amount'), + ] + $this->pageChrome($request, $company)); + + return $this->emit($request, 'profit-loss'); + } + + /** + * The company named by the hash, once the caller has been let through. + * + * Nothing upstream tells Bouncer which company to weigh abilities against: + * these links carry no company header, and the report ability is stored + * per company, so the unscoped check matched nothing and every report + * answered 403. Pointing the scope at the company in the URL settles that + * without widening access, because the policy still asks for membership. + * The hash is an address, not a credential. + * + * @param string $hash + */ + private function reportedCompany($hash): Company + { + $company = Company::query()->where('unique_hash', $hash)->firstOrFail(); + + BouncerFacade::scope()->to($company->id); + + $this->authorize('view report', $company); + + return $company; + } + + /** + * What every report prints around its figures: the company and its logo, + * the window in the company's own date format, and the currency the + * amounts are stated in. + * + * @return array + */ + private function pageChrome(Request $request, Company $company): array + { + $pattern = CompanySetting::getSetting('carbon_date_format', $company->id); + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($pattern); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date)->translatedFormat($pattern); + $currencyId = CompanySetting::getSetting('currency', $company->id); + $currency = Currency::findOrFail($currencyId); + + return [ + 'company' => $company, + 'logo' => $company->logo_path, + 'from_date' => $opened, + 'to_date' => $closed, + 'currency' => $currency, + ]; + } + + /** + * Hand the rendered report over in whichever of the three shapes the query + * string asks for. + * + * Reports have no template chooser, so an override is a file of the same + * name dropped into storage/app/templates/pdf/reports/, which the resolver + * prefers over the built-in one. + * + * The document is built before the preview branch is taken and not after: + * a preview costs a full render it never uses, which is wasteful but is + * also what the templates have always been exercised through. + */ + private function emit(Request $request, string $design) + { + $design = PdfTemplateUtils::resolveView('reports', $design); + + $document = Pdf::loadView($design, [], PdfPageSetup::forReports()); + + if ($request->exists('preview')) { + return view($design); + } + + return $request->exists('download') ? $document->download() : $document->stream(); + } +} diff --git a/app/Domains/Reporting/Http/Controllers/TaxSummaryReportController.php b/app/Domains/Reporting/Http/Controllers/TaxSummaryReportController.php new file mode 100644 index 00000000..d0f71e08 --- /dev/null +++ b/app/Domains/Reporting/Http/Controllers/TaxSummaryReportController.php @@ -0,0 +1,143 @@ +reportedCompany($hash); + + App::setLocale(CompanySetting::getSetting('language', $company->id)); + + $window = $request->only(['from_date', 'to_date']); + + $collected = Tax::query() + ->with('taxType') + ->whereCompany($company->id) + ->whereInvoicesFilters($window) + ->taxAttributes() + ->get(); + + $collectedTotal = (int) $collected->sum('total_tax_amount'); + + $paid = Tax::query() + ->with('taxType') + ->whereCompany($company->id) + ->whereExpensesFilters($window) + ->taxAttributes() + ->get(); + + $paidTotal = (int) $paid->sum('total_tax_amount'); + + view()->share([ + 'taxTypes' => $collected, + 'totalTaxAmount' => $collectedTotal, + 'expenseTaxTypes' => $paid, + 'totalExpenseTaxAmount' => $paidTotal, + 'netTaxAmount' => $collectedTotal - $paidTotal, + ] + $this->pageChrome($request, $company)); + + return $this->emit($request, 'tax-summary'); + } + + /** + * The company named by the hash, once the caller has been let through. + * + * Nothing upstream tells Bouncer which company to weigh abilities against: + * these links carry no company header, and the report ability is stored + * per company, so the unscoped check matched nothing and every report + * answered 403. Pointing the scope at the company in the URL settles that + * without widening access, because the policy still asks for membership. + * The hash is an address, not a credential. + * + * @param string $hash + */ + private function reportedCompany($hash): Company + { + $company = Company::query()->where('unique_hash', $hash)->firstOrFail(); + + BouncerFacade::scope()->to($company->id); + + $this->authorize('view report', $company); + + return $company; + } + + /** + * What every report prints around its figures: the company and its logo, + * the window in the company's own date format, and the currency the + * amounts are stated in. + * + * @return array + */ + private function pageChrome(Request $request, Company $company): array + { + $pattern = CompanySetting::getSetting('carbon_date_format', $company->id); + $opened = Carbon::createFromFormat('Y-m-d', $request->from_date)->translatedFormat($pattern); + $closed = Carbon::createFromFormat('Y-m-d', $request->to_date)->translatedFormat($pattern); + $currencyId = CompanySetting::getSetting('currency', $company->id); + $currency = Currency::findOrFail($currencyId); + + return [ + 'company' => $company, + 'logo' => $company->logo_path, + 'from_date' => $opened, + 'to_date' => $closed, + 'currency' => $currency, + ]; + } + + /** + * Hand the rendered report over in whichever of the three shapes the query + * string asks for. + * + * Reports have no template chooser, so an override is a file of the same + * name dropped into storage/app/templates/pdf/reports/, which the resolver + * prefers over the built-in one. + * + * The document is built before the preview branch is taken and not after: + * a preview costs a full render it never uses, which is wasteful but is + * also what the templates have always been exercised through. + */ + private function emit(Request $request, string $design) + { + $design = PdfTemplateUtils::resolveView('reports', $design); + + $document = Pdf::loadView($design, [], PdfPageSetup::forReports()); + + if ($request->exists('preview')) { + return view($design); + } + + return $request->exists('download') ? $document->download() : $document->stream(); + } +} diff --git a/app/Domains/Reporting/Policies/DashboardPolicy.php b/app/Domains/Reporting/Policies/DashboardPolicy.php new file mode 100644 index 00000000..87827f12 --- /dev/null +++ b/app/Domains/Reporting/Policies/DashboardPolicy.php @@ -0,0 +1,27 @@ +hasCompany($company->id); + } +} diff --git a/app/Domains/Reporting/Policies/ReportPolicy.php b/app/Domains/Reporting/Policies/ReportPolicy.php new file mode 100644 index 00000000..64eac5a4 --- /dev/null +++ b/app/Domains/Reporting/Policies/ReportPolicy.php @@ -0,0 +1,30 @@ +hasCompany($company->id); + } +}