mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-01 12:51:00 +00:00
feat(reporting): fresh reporting implementation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user