feat(reporting): fresh reporting implementation

This commit is contained in:
Darko Gjorgjijoski
2026-08-21 10:51:26 +02:00
parent 923a383ec5
commit cbd739db0f
9 changed files with 1018 additions and 0 deletions
@@ -0,0 +1,200 @@
<?php
namespace App\Domains\Reporting\Http\Controllers\Company;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Purchases\Models\Expense;
use App\Domains\Receivables\Models\Payment;
use App\Domains\Sales\Models\Estimate;
use App\Domains\Sales\Models\Invoice;
use App\Platform\Http\Controller;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Silber\Bouncer\BouncerFacade;
/**
* The company overview: a twelve-month money series, the headline counters and
* the two "latest activity" lists.
*
* The series is anchored on the company's `fiscal_year` preference, whose first
* dash-separated component names the opening month. Anything the parser cannot
* read — the shipped default is the word "calendar_year" — intval()s to zero,
* and month zero rolls Carbon back into December of the year before. That is
* the window those companies really get, so it is reproduced rather than
* corrected.
*/
class DashboardController extends Controller
{
/**
* @return JsonResponse
*/
public function __invoke(Request $request)
{
$companyId = $request->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,
]);
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Domains\Reporting\Http\Controllers\Company;
use App\Domains\Accounts\Models\User;
use App\Domains\Contacts\Models\Customer;
use App\Platform\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* The header search box, and the lookup behind "invite an existing account".
*/
class SearchController extends Controller
{
/**
* Contacts of the active company, newest first, plus — for an owner only —
* the members matching the same term.
*
* @return Response
*/
public function __invoke(Request $request)
{
$term = $request->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),
]);
}
}
@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Reporting\Http\Controllers;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Contacts\Models\Customer;
use App\Domains\Money\Models\Currency;
use App\Platform\Http\Controller;
use App\Platform\Pdf\Facades\Pdf;
use App\Platform\Pdf\Rendering\PdfPageSetup;
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Silber\Bouncer\BouncerFacade;
/**
* Turnover of a period broken down by the customer it came from.
*
* A customer earns a block on the page by having at least one document dated
* inside the window; the block lists those documents and what they came to in
* the company's own currency, and the page total is the sum of the blocks.
* Credit notes count as documents here, so a reversal both keeps its customer
* on the page and drags the figures down with it.
*/
class CustomerSalesReportController extends Controller
{
/**
* Render the report for the company the hash names.
*
* @param string $hash
*/
public function __invoke(Request $request, $hash)
{
$company = $this->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<string, mixed>
*/
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();
}
}
@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Reporting\Http\Controllers;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Domains\Purchases\Models\Expense;
use App\Platform\Http\Controller;
use App\Platform\Pdf\Facades\Pdf;
use App\Platform\Pdf\Rendering\PdfPageSetup;
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Silber\Bouncer\BouncerFacade;
/**
* What the company spent over a period, gathered under the categories it
* files spending against.
*
* The rows are read oldest first and then bucketed, so each category keeps the
* chronological order the ledger has; spending filed against a category that
* has since been removed lands under a translated stand-in. Narrowing the
* request to a single category narrows the page to that one bucket.
*/
class ExpensesReportController extends Controller
{
/**
* Render the report for the company the hash names.
*
* @param string $hash
*/
public function __invoke(Request $request, $hash)
{
$company = $this->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<string, mixed>
*/
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();
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Domains\Reporting\Http\Controllers;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Domains\Sales\Models\InvoiceItem;
use App\Platform\Http\Controller;
use App\Platform\Pdf\Facades\Pdf;
use App\Platform\Pdf\Rendering\PdfPageSetup;
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Silber\Bouncer\BouncerFacade;
/**
* What was sold over a period, one row per thing sold.
*
* The rows are collapsed by the name printed on the invoice line rather than
* by the catalogue entry behind it, so renaming an item splits its history in
* two and a one-off line typed straight onto an invoice gets a row of its own
* alongside the catalogue ones. Quantities and amounts are summed by the
* database, which is why a quantity column costs the page nothing.
*/
class ItemSalesReportController extends Controller
{
/**
* Render the report for the company the hash names.
*
* @param string $hash
*/
public function __invoke(Request $request, $hash)
{
$company = $this->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<string, mixed>
*/
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();
}
}
@@ -0,0 +1,135 @@
<?php
namespace App\Domains\Reporting\Http\Controllers;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Domains\Purchases\Models\Expense;
use App\Domains\Receivables\Models\Payment;
use App\Platform\Http\Controller;
use App\Platform\Pdf\Facades\Pdf;
use App\Platform\Pdf\Rendering\PdfPageSetup;
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Silber\Bouncer\BouncerFacade;
/**
* Money in against money out over a period.
*
* Income is what was actually received in the window rather than what was
* billed, so an unpaid invoice contributes nothing; the other side is spending
* rolled up per category, each with a count and a sum. The two figures are
* handed over side by side and the template is what subtracts one from the
* other.
*/
class ProfitLossReportController extends Controller
{
/**
* Render the report for the company the hash names.
*
* @param string $hash
*/
public function __invoke(Request $request, $hash)
{
$company = $this->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<string, mixed>
*/
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();
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Domains\Reporting\Http\Controllers;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\CompanySetting;
use App\Domains\Money\Models\Currency;
use App\Domains\Taxation\Models\Tax;
use App\Platform\Http\Controller;
use App\Platform\Pdf\Facades\Pdf;
use App\Platform\Pdf\Rendering\PdfPageSetup;
use App\Platform\Pdf\Rendering\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Silber\Bouncer\BouncerFacade;
/**
* Tax collected against tax paid over a period, per tax type.
*
* The collected side counts only tax recorded against invoices that have been
* settled, since tax on an invoice still owing has not been collected, and it
* picks up rows attached to a line as readily as rows attached to the document.
* The paid side has no such condition: an expense is money already out of the
* door. What is left over is the balance with the tax authority, which is
* payable when positive and refundable when negative.
*/
class TaxSummaryReportController extends Controller
{
/**
* Render the report for the company the hash names.
*
* @param string $hash
*/
public function __invoke(Request $request, $hash)
{
$company = $this->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<string, mixed>
*/
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();
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Reporting\Policies;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Silber\Bouncer\BouncerFacade;
/**
* Who may open the company overview.
*/
class DashboardPolicy
{
use HandlesAuthorization;
/**
* The ability alone is not enough: the account has to belong to the
* company it is asking about, so a granted role in one tenancy cannot be
* turned on another.
*/
public function view(User $user, Company $company): bool
{
return BouncerFacade::can('dashboard')
&& $user->hasCompany($company->id);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Reporting\Policies;
use App\Domains\Accounts\Models\Company;
use App\Domains\Accounts\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
use Silber\Bouncer\BouncerFacade;
/**
* Who may pull the financial reports.
*/
class ReportPolicy
{
use HandlesAuthorization;
/**
* Membership is checked alongside the ability. The report URLs address a
* company by its hash, and that hash is an address rather than a
* credential: holding it opens nothing on its own.
*
* The return type stays undeclared: this is the signature the gate has
* always exposed, and reflection over it is part of the contract.
*/
public function viewReport(User $user, Company $company)
{
return BouncerFacade::can('view-financial-reports')
&& $user->hasCompany($company->id);
}
}